diff --git a/lib/blocks.php b/lib/blocks.php index e98f711b5c85a..f160d2a7080d3 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -172,6 +172,40 @@ function gutenberg_reregister_core_block_types() { add_action( 'init', 'gutenberg_reregister_core_block_types' ); +/** + * Adds the defer loading strategy to all registered blocks. + * + * This function would not be part of core merge. Instead, the register_block_script_handle() function would be patched + * as follows. + * + * ``` + * --- a/wp-includes/blocks.php + * +++ b/wp-includes/blocks.php + * @ @ -153,7 +153,8 @ @ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { + * $script_handle, + * $script_uri, + * $script_dependencies, + * - isset( $script_asset['version'] ) ? $script_asset['version'] : false + * + isset( $script_asset['version'] ) ? $script_asset['version'] : false, + * + array( 'strategy' => 'defer' ) + * ); + * if ( ! $result ) { + * return false; + * ``` + * + * @see register_block_script_handle() + */ +function gutenberg_defer_block_view_scripts() { + $block_types = WP_Block_Type_Registry::get_instance()->get_all_registered(); + foreach ( $block_types as $block_type ) { + foreach ( $block_type->view_script_handles as $view_script_handle ) { + wp_script_add_data( $view_script_handle, 'strategy', 'defer' ); + } + } +} + +add_action( 'init', 'gutenberg_defer_block_view_scripts', 100 ); + /** * Deregisters the existing core block type and its assets. * diff --git a/lib/experimental/interactivity-api/scripts.php b/lib/experimental/interactivity-api/scripts.php index e95bf518c75f7..ed1fca8550070 100644 --- a/lib/experimental/interactivity-api/scripts.php +++ b/lib/experimental/interactivity-api/scripts.php @@ -7,20 +7,32 @@ */ /** - * Move interactive scripts to the footer. This is a temporary measure to make - * it work with `wp_store` and it should be replaced with deferred scripts or - * modules. + * Makes sure that interactivity scripts execute after all `wp_store` directives have been printed to the page. + * + * In WordPress 6.3+ this is achieved by printing in the head but marking the scripts with defer. This has the benefit + * of early discovery so the script is loaded by the browser, while at the same time not blocking rendering. In older + * versions of WordPress, this is achieved by loading the scripts in the footer. + * + * @link https://make.wordpress.org/core/2023/07/14/registering-scripts-with-async-and-defer-attributes-in-wordpress-6-3/ */ function gutenberg_interactivity_move_interactive_scripts_to_the_footer() { - // Move the @wordpress/interactivity package to the footer. - wp_script_add_data( 'wp-interactivity', 'group', 1 ); + $supports_defer = version_compare( strtok( get_bloginfo( 'version' ), '-' ), '6.3', '>=' ); + if ( $supports_defer ) { + // Defer execution of @wordpress/interactivity package but continue loading in head. + wp_script_add_data( 'wp-interactivity', 'strategy', 'defer' ); + wp_script_add_data( 'wp-interactivity', 'group', 0 ); + } else { + // Move the @wordpress/interactivity package to the footer. + wp_script_add_data( 'wp-interactivity', 'group', 1 ); + } // Move all the view scripts of the interactive blocks to the footer. $registered_blocks = \WP_Block_Type_Registry::get_instance()->get_all_registered(); foreach ( array_values( $registered_blocks ) as $block ) { if ( isset( $block->supports['interactivity'] ) && $block->supports['interactivity'] ) { foreach ( $block->view_script_handles as $handle ) { - wp_script_add_data( $handle, 'group', 1 ); + // Note that all block view scripts are already made defer by default. + wp_script_add_data( $handle, 'group', $supports_defer ? 0 : 1 ); } } } diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 621f07b5b88ee..7dd77b20f466c 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -24,7 +24,7 @@ function gutenberg_block_core_file_update_interactive_view_script( $metadata ) { } /** - * When the `core/file` block is rendering, check if we need to enqueue the `'wp-block-file-view` script. + * When the `core/file` block is rendering, check if we need to enqueue the `wp-block-file-view` script. * * @param array $attributes The block attributes. * @param string $content The block content. diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 79988c3ee350b..52376aba0d44e 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -671,19 +671,33 @@ function render_block_core_navigation( $attributes, $content, $block ) { $inner_blocks_html .= ''; } - // If the script already exists, there is no point in removing it from viewScript. - $should_load_view_script = ( $is_responsive_menu || ( $has_submenus && ( $attributes['openSubmenusOnClick'] || $attributes['showSubmenuIcon'] ) ) ); - $view_js_file = 'wp-block-navigation-view'; - if ( ! wp_script_is( $view_js_file ) ) { - $script_handles = $block->block_type->view_script_handles; - - // If the script is not needed, and it is still in the `view_script_handles`, remove it. - if ( ! $should_load_view_script && in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_js_file, 'wp-block-navigation-view-2' ) ); - } - // If the script is needed, but it was previously removed, add it again. - if ( $should_load_view_script && ! in_array( $view_js_file, $script_handles, true ) ) { - $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_js_file, 'wp-block-navigation-view-2' ) ); + $needed_script_map = array( + 'wp-block-navigation-view' => ( $has_submenus && ( $attributes['openSubmenusOnClick'] || $attributes['showSubmenuIcon'] ) ), + 'wp-block-navigation-view-2' => $is_responsive_menu, + ); + + $should_load_view_script = false; + if ( gutenberg_should_block_use_interactivity_api( 'core/navigation' ) ) { + // TODO: The script is still loaded even when it isn't needed when the Interactivity API is used. + $should_load_view_script = count( array_filter( $needed_script_map ) ) > 0; + } else { + foreach ( $needed_script_map as $view_script_handle => $is_view_script_needed ) { + + // If the script already exists, there is no point in removing it from viewScript. + if ( wp_script_is( $view_script_handle ) ) { + continue; + } + + $script_handles = $block->block_type->view_script_handles; + + // If the script is not needed, and it is still in the `view_script_handles`, remove it. + if ( ! $is_view_script_needed && in_array( $view_script_handle, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_diff( $script_handles, array( $view_script_handle ) ); + } + // If the script is needed, but it was previously removed, add it again. + if ( $is_view_script_needed && ! in_array( $view_script_handle, $script_handles, true ) ) { + $block->block_type->view_script_handles = array_merge( $script_handles, array( $view_script_handle ) ); + } } } diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js index 9477d262816d9..62de6e8808bf0 100644 --- a/packages/block-library/src/navigation/view-modal.js +++ b/packages/block-library/src/navigation/view-modal.js @@ -1,16 +1,22 @@ +/*eslint-env browser*/ /** * External dependencies */ import MicroModal from 'micromodal'; // Responsive navigation toggle. -function navigationToggleModal( modal ) { + +/** + * Toggles responsive navigation. + * + * @param {HTMLDivElement} modal + * @param {boolean} isHidden + */ +function navigationToggleModal( modal, isHidden ) { const dialogContainer = modal.querySelector( `.wp-block-navigation__responsive-dialog` ); - const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); - modal.classList.toggle( 'has-modal-open', ! isHidden ); dialogContainer.toggleAttribute( 'aria-modal', ! isHidden ); @@ -23,10 +29,15 @@ function navigationToggleModal( modal ) { } // Add a class to indicate the modal is open. - const htmlElement = document.documentElement; - htmlElement.classList.toggle( 'has-modal-open' ); + document.documentElement.classList.toggle( 'has-modal-open' ); } +/** + * Checks whether the provided link is an anchor on the current page. + * + * @param {HTMLAnchorElement} node + * @return {boolean} Is anchor. + */ function isLinkToAnchorOnCurrentPage( node ) { return ( node.hash && @@ -37,42 +48,80 @@ function isLinkToAnchorOnCurrentPage( node ) { ); } -window.addEventListener( 'load', () => { - MicroModal.init( { - onShow: navigationToggleModal, - onClose: navigationToggleModal, - openClass: 'is-menu-open', +/** + * Handles effects after opening the modal. + * + * @param {HTMLDivElement} modal + */ +function onShow( modal ) { + navigationToggleModal( modal, false ); + modal.addEventListener( 'click', handleAnchorLinkClicksInsideModal, { + passive: true, } ); +} - // Close modal automatically on clicking anchor links inside modal. - const navigationLinks = document.querySelectorAll( - '.wp-block-navigation-item__content' - ); +/** + * Handles effects after closing the modal. + * + * @param {HTMLDivElement} modal + */ +function onClose( modal ) { + navigationToggleModal( modal, true ); + modal.removeEventListener( 'click', handleAnchorLinkClicksInsideModal, { + passive: true, + } ); +} - navigationLinks.forEach( function ( link ) { - // Ignore non-anchor links and anchor links which open on a new tab. - if ( - ! isLinkToAnchorOnCurrentPage( link ) || - link.attributes?.target === '_blank' - ) { - return; - } +/** + * Handle clicks to anchor links in modal using event delegation by closing modal automatically + * + * @param {UIEvent} event + */ +function handleAnchorLinkClicksInsideModal( event ) { + const link = event.target.closest( '.wp-block-navigation-item__content' ); + if ( ! ( link instanceof HTMLAnchorElement ) ) { + return; + } - // Find the specific parent modal for this link - // since .close() won't work without an ID if there are - // multiple navigation menus in a post/page. - const modal = link.closest( - '.wp-block-navigation__responsive-container' - ); - const modalId = modal?.getAttribute( 'id' ); + // Ignore non-anchor links and anchor links which open on a new tab. + if ( + ! isLinkToAnchorOnCurrentPage( link ) || + link.attributes?.target === '_blank' + ) { + return; + } - link.addEventListener( 'click', () => { - // check if modal exists and is open before trying to close it - // otherwise Micromodal will toggle the `has-modal-open` class - // on the html tag which prevents scrolling - if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { - MicroModal.close( modalId ); - } - } ); - } ); -} ); + // Find the specific parent modal for this link + // since .close() won't work without an ID if there are + // multiple navigation menus in a post/page. + const modal = link.closest( '.wp-block-navigation__responsive-container' ); + const modalId = modal?.getAttribute( 'id' ); + if ( ! modalId ) { + return; + } + + // check if modal exists and is open before trying to close it + // otherwise Micromodal will toggle the `has-modal-open` class + // on the html tag which prevents scrolling + if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { + MicroModal.close( modalId ); + } +} + +// MicroModal.init() does not support event delegation for the open trigger, so here MicroModal.show() is called manually. +document.addEventListener( + 'click', + ( event ) => { + /** @type {HTMLElement} */ + const target = event.target; + + if ( target.dataset.micromodalTrigger ) { + MicroModal.show( target.dataset.micromodalTrigger, { + onShow, + onClose, + openClass: 'is-menu-open', + } ); + } + }, + { passive: true } +); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 19805a44ae4ae..d808d1707d5bf 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,62 +1,94 @@ +/*eslint-env browser*/ // Open on click functionality. -function closeSubmenus( element ) { - element + +/** + * Keep track of whether a submenu is open to short-circuit delegated event listeners. + * + * @type {boolean} + */ +let hasOpenSubmenu = false; + +/** + * Close submenu items for a navigation item. + * + * @param {HTMLElement} navigationItem - Either a NAV or LI element. + */ +function closeSubmenus( navigationItem ) { + navigationItem .querySelectorAll( '[aria-expanded="true"]' ) .forEach( function ( toggle ) { toggle.setAttribute( 'aria-expanded', 'false' ); } ); + hasOpenSubmenu = false; } -function toggleSubmenuOnClick( event ) { - const buttonToggle = event.target.closest( '[aria-expanded]' ); - const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); +/** + * Toggle submenu on click. + * + * @param {HTMLButtonElement} buttonToggle + */ +function toggleSubmenuOnClick( buttonToggle ) { + const isSubmenuOpen = + buttonToggle.getAttribute( 'aria-expanded' ) === 'true'; + const navigationItem = buttonToggle.closest( '.wp-block-navigation-item' ); - if ( isSubmenuOpen === 'true' ) { - closeSubmenus( buttonToggle.closest( '.wp-block-navigation-item' ) ); + if ( isSubmenuOpen ) { + closeSubmenus( navigationItem ); } else { // Close all sibling submenus. - const parentElement = buttonToggle.closest( - '.wp-block-navigation-item' - ); const navigationParent = buttonToggle.closest( '.wp-block-navigation__submenu-container, .wp-block-navigation__container, .wp-block-page-list' ); navigationParent .querySelectorAll( '.wp-block-navigation-item' ) - .forEach( function ( child ) { - if ( child !== parentElement ) { + .forEach( ( child ) => { + if ( child !== navigationItem ) { closeSubmenus( child ); } } ); + // Open submenu. buttonToggle.setAttribute( 'aria-expanded', 'true' ); + hasOpenSubmenu = true; } } -// Necessary for some themes such as TT1 Blocks, where -// scripts could be loaded before the body. -window.addEventListener( 'load', () => { - const submenuButtons = document.querySelectorAll( - '.wp-block-navigation-submenu__toggle' - ); +// Open on button click or close on click outside. +document.addEventListener( + 'click', + function ( event ) { + const target = event.target; + const button = target.closest( '.wp-block-navigation-submenu__toggle' ); - submenuButtons.forEach( function ( button ) { - button.addEventListener( 'click', toggleSubmenuOnClick ); - } ); + // Close any other open submenus. + if ( hasOpenSubmenu ) { + const navigationBlocks = document.querySelectorAll( + '.wp-block-navigation' + ); + navigationBlocks.forEach( function ( block ) { + if ( ! block.contains( target ) ) { + closeSubmenus( block ); + } + } ); + } + + // Now open the submenu if one was clicked. + if ( button instanceof HTMLButtonElement ) { + toggleSubmenuOnClick( button ); + } + }, + { passive: true } +); + +// Close on focus outside or escape key. +document.addEventListener( + 'keyup', + function ( event ) { + // Abort if there aren't any submenus open anyway. + if ( ! hasOpenSubmenu ) { + return; + } - // Close on click outside. - document.addEventListener( 'click', function ( event ) { - const navigationBlocks = document.querySelectorAll( - '.wp-block-navigation' - ); - navigationBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } - } ); - } ); - // Close on focus outside or escape key. - document.addEventListener( 'keyup', function ( event ) { const submenuBlocks = document.querySelectorAll( '.wp-block-navigation-item.has-child' ); @@ -70,5 +102,6 @@ window.addEventListener( 'load', () => { toggle?.focus(); } } ); - } ); -} ); + }, + { passive: true } +); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index 700a299a92457..892b5163cfab0 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -138,8 +138,10 @@ function render_block_core_search( $attributes, $content, $block ) { $button->add_class( implode( ' ', $button_classes ) ); if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); + $button->set_attribute( 'data-toggled-aria-label', __( 'Submit Search' ) ); $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); $button->set_attribute( 'aria-expanded', 'false' ); + $button->set_attribute( 'type', 'button' ); // Will be set to submit after clicking. } else { $button->set_attribute( 'aria-label', wp_strip_all_tags( $attributes['buttonText'] ) ); } diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index 0909121b25bf0..5aaf1dd1ef3ad 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,68 +1,172 @@ -window.addEventListener( 'DOMContentLoaded', () => { - const hiddenClass = 'wp-block-search__searchfield-hidden'; - - Array.from( - document.getElementsByClassName( - 'wp-block-search__button-behavior-expand' - ) - ).forEach( ( block ) => { - const searchField = block.querySelector( '.wp-block-search__input' ); - const searchButton = block.querySelector( '.wp-block-search__button' ); - const searchLabel = block.querySelector( '.wp-block-search__label' ); - const ariaLabel = searchButton.getAttribute( 'aria-label' ); - const id = searchField.getAttribute( 'id' ); - - const toggleSearchField = ( showSearchField ) => { - if ( showSearchField ) { - searchField.removeAttribute( 'aria-hidden' ); - searchField.removeAttribute( 'tabindex' ); - searchButton.removeAttribute( 'aria-expanded' ); - searchButton.removeAttribute( 'aria-controls' ); - searchButton.setAttribute( 'type', 'submit' ); - searchButton.setAttribute( 'aria-label', 'Submit Search' ); - - return block.classList.remove( hiddenClass ); - } - - searchButton.removeAttribute( 'type' ); - searchField.setAttribute( 'aria-hidden', 'true' ); - searchField.setAttribute( 'tabindex', '-1' ); - searchButton.setAttribute( 'aria-expanded', 'false' ); - searchButton.setAttribute( 'aria-controls', id ); - searchButton.setAttribute( 'aria-label', ariaLabel ); - return block.classList.add( hiddenClass ); - }; - - const hideSearchField = ( e ) => { - if ( ! e.target.closest( '.wp-block-search' ) ) { - return toggleSearchField( false ); - } - - if ( e.key === 'Escape' ) { - searchButton.focus(); - return toggleSearchField( false ); - } - }; - - const handleButtonClick = ( e ) => { - if ( block.classList.contains( hiddenClass ) ) { - e.preventDefault(); - searchField.focus(); - toggleSearchField( true ); - } - }; - - searchButton.removeAttribute( 'type' ); - searchField.addEventListener( 'keydown', ( e ) => { - hideSearchField( e ); - } ); - searchButton.addEventListener( 'click', handleButtonClick ); - searchButton.addEventListener( 'keydown', ( e ) => { - hideSearchField( e ); - } ); - if ( searchLabel ) { - searchLabel.addEventListener( 'click', handleButtonClick ); - } - document.body.addEventListener( 'click', hideSearchField ); +/*eslint-env browser*/ + +/** @type {?HTMLFormElement} */ +let expandedSearchBlock = null; + +const hiddenClass = 'wp-block-search__searchfield-hidden'; + +/** + * Toggles aria-label with data-toggled-aria-label. + * + * @param {HTMLElement} element + */ +function toggleAriaLabel( element ) { + if ( ! ( 'toggledAriaLabel' in element.dataset ) ) { + throw new Error( 'Element lacks toggledAriaLabel in dataset.' ); + } + + const ariaLabel = element.dataset.toggledAriaLabel; + element.dataset.toggledAriaLabel = element.ariaLabel; + element.ariaLabel = ariaLabel; +} + +/** + * Gets search input. + * + * @param {HTMLFormElement} block Search block. + * @return {HTMLInputElement} Search input. + */ +function getSearchInput( block ) { + return block.querySelector( '.wp-block-search__input' ); +} + +/** + * Gets search button. + * + * @param {HTMLFormElement} block Search block. + * @return {HTMLButtonElement} Search button. + */ +function getSearchButton( block ) { + return block.querySelector( '.wp-block-search__button' ); +} + +/** + * Handles keydown event to collapse an expanded Search block (when pressing Escape key). + * + * @param {KeyboardEvent} event + */ +function handleKeydownEvent( event ) { + if ( ! expandedSearchBlock ) { + // In case the event listener wasn't removed in time. + return; + } + + if ( event.key === 'Escape' ) { + const block = expandedSearchBlock; // This is nullified by collapseExpandedSearchBlock(). + collapseExpandedSearchBlock(); + getSearchButton( block ).focus(); + } +} + +/** + * Handles keyup event to collapse an expanded Search block (e.g. when tabbing out of expanded Search block). + * + * @param {KeyboardEvent} event + */ +function handleKeyupEvent( event ) { + if ( ! expandedSearchBlock ) { + // In case the event listener wasn't removed in time. + return; + } + + if ( event.target.closest( '.wp-block-search' ) !== expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } +} + +/** + * Expands search block. + * + * Inverse of what is done in collapseExpandedSearchBlock(). + * + * @param {HTMLFormElement} block Search block. + */ +function expandSearchBlock( block ) { + // Make sure only one is open at a time. + if ( expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } + + const searchField = getSearchInput( block ); + const searchButton = getSearchButton( block ); + + searchButton.type = 'submit'; + searchField.ariaHidden = 'false'; + searchField.tabIndex = 0; + searchButton.ariaExpanded = 'true'; + searchButton.removeAttribute( 'aria-controls' ); // Note: Seemingly not reflected with searchButton.ariaControls. + toggleAriaLabel( searchButton ); + block.classList.remove( hiddenClass ); + + searchField.focus(); // Note that Chrome seems to do this automatically. + + // The following two must be inverse of what is done in collapseExpandedSearchBlock(). + document.addEventListener( 'keydown', handleKeydownEvent, { + passive: true, + } ); + document.addEventListener( 'keyup', handleKeyupEvent, { + passive: true, + } ); + + expandedSearchBlock = block; +} + +/** + * Collapses the expanded search block. + * + * Inverse of what is done in expandSearchBlock(). + */ +function collapseExpandedSearchBlock() { + if ( ! expandedSearchBlock ) { + throw new Error( 'Expected expandedSearchBlock to be defined.' ); + } + const block = expandedSearchBlock; + const searchField = getSearchInput( block ); + const searchButton = getSearchButton( block ); + + searchButton.type = 'button'; + searchField.ariaHidden = 'true'; + searchField.tabIndex = -1; + searchButton.ariaExpanded = 'false'; + searchButton.setAttribute( 'aria-controls', searchField.id ); // Note: Seemingly not reflected with searchButton.ariaControls. + toggleAriaLabel( searchButton ); + block.classList.add( hiddenClass ); + + // The following two must be inverse of what is done in expandSearchBlock(). + document.removeEventListener( 'keydown', handleKeydownEvent, { + passive: true, } ); -} ); + document.removeEventListener( 'keyup', handleKeyupEvent, { + passive: true, + } ); + + expandedSearchBlock = null; +} + +// Listen for click events anywhere on the document so this script can be loaded asynchronously in the head. +document.addEventListener( + 'click', + ( event ) => { + // Get the ancestor expandable Search block of the clicked element. + const block = event.target.closest( + '.wp-block-search__button-behavior-expand' + ); + + /* + * If there is already an expanded search block and either the current click was not for a Search block or it was + * for another block, then collapse the currently-expanded block. + */ + if ( expandedSearchBlock && block !== expandedSearchBlock ) { + collapseExpandedSearchBlock(); + } + + // If the click was on or inside a collapsed Search block, expand it. + if ( + block instanceof HTMLFormElement && + block.classList.contains( hiddenClass ) + ) { + expandSearchBlock( block ); + } + }, + { passive: true } +);