diff --git a/package-lock.json b/package-lock.json index f552635bec7cc..bec53278a4ee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17348,6 +17348,7 @@ "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "memize": "^2.1.0", + "micromodal": "^0.4.10", "remove-accents": "^0.4.2", "uuid": "^8.3.0" } @@ -43507,6 +43508,11 @@ "picomatch": "^2.3.1" } }, + "micromodal": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/micromodal/-/micromodal-0.4.10.tgz", + "integrity": "sha512-BUrEnzMPFBwK8nOE4xUDYHLrlGlLULQVjpja99tpJQPSUEWgw3kTLp1n1qv0HmKU29AiHE7Y7sMLiRziDK4ghQ==" + }, "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 a5eebe6bbc9d0..8799a70862005 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -68,6 +68,7 @@ "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "memize": "^2.1.0", + "micromodal": "^0.4.10", "remove-accents": "^0.4.2", "uuid": "^8.3.0" }, diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index a7011cc9efa34..172bb5d836343 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -5,6 +5,23 @@ * @package WordPress */ +if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { + /** + * Replaces view script for the File block with version using Interactivity API. + * + * @param array $metadata Block metadata as read in via block.json. + * + * @return array Filtered block type metadata. + */ + function gutenberg_block_core_file_update_interactive_view_script( $metadata ) { + if ( 'core/file' === $metadata['name'] ) { + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); + } + return $metadata; + } + add_filter( 'block_type_metadata', 'gutenberg_block_core_file_update_interactive_view_script', 10, 1 ); +} + /** * When the `core/file` block is rendering, check if we need to enqueue the `'wp-block-file-view` script. * diff --git a/packages/block-library/src/file/view-interactivity.js b/packages/block-library/src/file/view-interactivity.js new file mode 100644 index 0000000000000..9d09ca2b7f434 --- /dev/null +++ b/packages/block-library/src/file/view-interactivity.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; +/** + * Internal dependencies + */ +import { browserSupportsPdfs as hasPdfPreview } from './utils'; + +store( { + selectors: { + core: { + file: { + hasPdfPreview, + }, + }, + }, +} ); diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index 9d09ca2b7f434..6d0b61fa51cb7 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -1,18 +1,9 @@ -/** - * WordPress dependencies - */ -import { store } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { browserSupportsPdfs as hasPdfPreview } from './utils'; +import { hidePdfEmbedsOnUnsupportedBrowsers } from './utils'; -store( { - selectors: { - core: { - file: { - hasPdfPreview, - }, - }, - }, -} ); +document.addEventListener( + 'DOMContentLoaded', + hidePdfEmbedsOnUnsupportedBrowsers +); diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 7896ea147699f..0fbb2f5ed2b91 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -134,7 +134,7 @@ }, "interactivity": true }, - "viewScript": "file:./view.min.js", + "viewScript": [ "file:./view.min.js", "file:./view-modal.min.js" ], "editorStyle": "wp-block-navigation-editor", "style": "wp-block-navigation" } diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index dbf6b8c0f5ed2..cfdd20100af81 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -138,6 +138,21 @@ function block_core_navigation_add_directives_to_submenu( $w, $block_attributes } return $w->get_updated_html(); }; + + /** + * Replaces view script for the Navigation block with version using Interactivity API. + * + * @param array $metadata Block metadata as read in via block.json. + * + * @return array Filtered block type metadata. + */ + function gutenberg_block_core_navigation_update_interactive_view_script( $metadata ) { + if ( 'core/navigation' === $metadata['name'] ) { + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); + } + return $metadata; + } + add_filter( 'block_type_metadata', 'gutenberg_block_core_navigation_update_interactive_view_script', 10, 1 ); } @@ -746,11 +761,11 @@ function render_block_core_navigation( $attributes, $content, $block ) { } $responsive_container_markup = sprintf( - ' + '
-
+
- +
%2$s
diff --git a/packages/block-library/src/navigation/view-interactivity.js b/packages/block-library/src/navigation/view-interactivity.js new file mode 100644 index 0000000000000..b0d39ef3ca4d5 --- /dev/null +++ b/packages/block-library/src/navigation/view-interactivity.js @@ -0,0 +1,196 @@ +/** + * WordPress dependencies + */ +import { store as wpStore } from '@wordpress/interactivity'; + +const focusableSelectors = [ + 'a[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', +]; + +const openMenu = ( store, menuOpenedOn ) => { + const { context, ref, selectors } = store; + selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; + context.core.navigation.previousFocus = ref; + if ( context.core.navigation.type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } +}; + +const closeMenu = ( store, menuClosedOn ) => { + const { context, selectors } = store; + selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! selectors.core.navigation.isMenuOpen( store ) ) { + if ( + context.core.navigation.modal?.contains( + window.document.activeElement + ) + ) { + context.core.navigation.previousFocus.focus(); + } + context.core.navigation.modal = null; + context.core.navigation.previousFocus = null; + if ( context.core.navigation.type === 'overlay' ) { + document.documentElement.classList.remove( 'has-modal-open' ); + } + } +}; + +wpStore( { + effects: { + core: { + navigation: { + initMenu: ( store ) => { + const { context, selectors, ref } = store; + if ( selectors.core.navigation.isMenuOpen( store ) ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + context.core.navigation.modal = ref; + context.core.navigation.firstFocusableElement = + focusableElements[ 0 ]; + context.core.navigation.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement: ( store ) => { + const { selectors, ref } = store; + if ( selectors.core.navigation.isMenuOpen( store ) ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } + }, + }, + }, + }, + selectors: { + core: { + navigation: { + roleAttribute: ( store ) => { + const { context, selectors } = store; + return context.core.navigation.type === 'overlay' && + selectors.core.navigation.isMenuOpen( store ) + ? 'dialog' + : ''; + }, + isMenuOpen: ( { context } ) => + // The menu is opened if either `click`, `hover` or `focus` is true. + Object.values( + context.core.navigation[ + context.core.navigation.type === 'overlay' + ? 'overlayOpenedBy' + : 'submenuOpenedBy' + ] + ).filter( Boolean ).length > 0, + menuOpenedBy: ( { context } ) => + context.core.navigation[ + context.core.navigation.type === 'overlay' + ? 'overlayOpenedBy' + : 'submenuOpenedBy' + ], + }, + }, + }, + actions: { + core: { + navigation: { + openMenuOnHover( store ) { + const { navigation } = store.context.core; + if ( + navigation.type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( + navigation.overlayOpenedBy || {} + ).filter( Boolean ).length === 0 + ) + openMenu( store, 'hover' ); + }, + closeMenuOnHover( store ) { + closeMenu( store, 'hover' ); + }, + openMenuOnClick( store ) { + openMenu( store, 'click' ); + }, + closeMenuOnClick( store ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + }, + openMenuOnFocus( store ) { + openMenu( store, 'focus' ); + }, + toggleMenuOnClick: ( store ) => { + const { selectors } = store; + const menuOpenedBy = + selectors.core.navigation.menuOpenedBy( store ); + if ( menuOpenedBy.click || menuOpenedBy.focus ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + } else { + openMenu( store, 'click' ); + } + }, + handleMenuKeydown: ( store ) => { + const { context, selectors, event } = store; + if ( + selectors.core.navigation.menuOpenedBy( store ).click + ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + return; + } + + // Trap focus if it is an overlay (main menu). + if ( + context.core.navigation.type === 'overlay' && + event.key === 'Tab' + ) { + // If shift + tab it change the direction. + if ( + event.shiftKey && + window.document.activeElement === + context.core.navigation + .firstFocusableElement + ) { + event.preventDefault(); + context.core.navigation.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + context.core.navigation.lastFocusableElement + ) { + event.preventDefault(); + context.core.navigation.firstFocusableElement.focus(); + } + } + } + }, + handleMenuFocusout: ( store ) => { + const { context, event } = store; + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. + if ( + ! context.core.navigation.modal?.contains( + event.relatedTarget + ) && + event.target !== window.document.activeElement + ) { + closeMenu( store, 'click' ); + closeMenu( store, 'focus' ); + } + }, + }, + }, + }, +} ); diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js new file mode 100644 index 0000000000000..9477d262816d9 --- /dev/null +++ b/packages/block-library/src/navigation/view-modal.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import MicroModal from 'micromodal'; + +// Responsive navigation toggle. +function navigationToggleModal( modal ) { + 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 ); + + if ( isHidden ) { + dialogContainer.removeAttribute( 'role' ); + dialogContainer.removeAttribute( 'aria-modal' ); + } else { + dialogContainer.setAttribute( 'role', 'dialog' ); + dialogContainer.setAttribute( 'aria-modal', 'true' ); + } + + // Add a class to indicate the modal is open. + const htmlElement = document.documentElement; + htmlElement.classList.toggle( 'has-modal-open' ); +} + +function isLinkToAnchorOnCurrentPage( node ) { + return ( + node.hash && + node.protocol === window.location.protocol && + node.host === window.location.host && + node.pathname === window.location.pathname && + node.search === window.location.search + ); +} + +window.addEventListener( 'load', () => { + MicroModal.init( { + onShow: navigationToggleModal, + onClose: navigationToggleModal, + openClass: 'is-menu-open', + } ); + + // Close modal automatically on clicking anchor links inside modal. + const navigationLinks = document.querySelectorAll( + '.wp-block-navigation-item__content' + ); + + 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; + } + + // 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' ); + + 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 ); + } + } ); + } ); +} ); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index b0d39ef3ca4d5..19805a44ae4ae 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,196 +1,74 @@ -/** - * WordPress dependencies - */ -import { store as wpStore } from '@wordpress/interactivity'; +// Open on click functionality. +function closeSubmenus( element ) { + element + .querySelectorAll( '[aria-expanded="true"]' ) + .forEach( function ( toggle ) { + toggle.setAttribute( 'aria-expanded', 'false' ); + } ); +} -const focusableSelectors = [ - 'a[href]', - 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', - 'select:not([disabled]):not([aria-hidden])', - 'textarea:not([disabled]):not([aria-hidden])', - 'button:not([disabled]):not([aria-hidden])', - '[contenteditable]', - '[tabindex]:not([tabindex^="-"])', -]; +function toggleSubmenuOnClick( event ) { + const buttonToggle = event.target.closest( '[aria-expanded]' ); + const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); -const openMenu = ( store, menuOpenedOn ) => { - const { context, ref, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; - context.core.navigation.previousFocus = ref; - if ( context.core.navigation.type === 'overlay' ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); + if ( isSubmenuOpen === 'true' ) { + closeSubmenus( buttonToggle.closest( '.wp-block-navigation-item' ) ); + } 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 ) { + closeSubmenus( child ); + } + } ); + // Open submenu. + buttonToggle.setAttribute( 'aria-expanded', 'true' ); } -}; +} -const closeMenu = ( store, menuClosedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! selectors.core.navigation.isMenuOpen( store ) ) { - if ( - context.core.navigation.modal?.contains( - window.document.activeElement - ) - ) { - context.core.navigation.previousFocus.focus(); - } - context.core.navigation.modal = null; - context.core.navigation.previousFocus = null; - if ( context.core.navigation.type === 'overlay' ) { - document.documentElement.classList.remove( 'has-modal-open' ); - } - } -}; +// 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' + ); -wpStore( { - effects: { - core: { - navigation: { - initMenu: ( store ) => { - const { context, selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.navigation.modal = ref; - context.core.navigation.firstFocusableElement = - focusableElements[ 0 ]; - context.core.navigation.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement: ( store ) => { - const { selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, - }, - }, - selectors: { - core: { - navigation: { - roleAttribute: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'dialog' - : ''; - }, - isMenuOpen: ( { context } ) => - // The menu is opened if either `click`, `hover` or `focus` is true. - Object.values( - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ] - ).filter( Boolean ).length > 0, - menuOpenedBy: ( { context } ) => - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ], - }, - }, - }, - actions: { - core: { - navigation: { - openMenuOnHover( store ) { - const { navigation } = store.context.core; - if ( - navigation.type === 'submenu' && - // Only open on hover if the overlay is closed. - Object.values( - navigation.overlayOpenedBy || {} - ).filter( Boolean ).length === 0 - ) - openMenu( store, 'hover' ); - }, - closeMenuOnHover( store ) { - closeMenu( store, 'hover' ); - }, - openMenuOnClick( store ) { - openMenu( store, 'click' ); - }, - closeMenuOnClick( store ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - }, - openMenuOnFocus( store ) { - openMenu( store, 'focus' ); - }, - toggleMenuOnClick: ( store ) => { - const { selectors } = store; - const menuOpenedBy = - selectors.core.navigation.menuOpenedBy( store ); - if ( menuOpenedBy.click || menuOpenedBy.focus ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - } else { - openMenu( store, 'click' ); - } - }, - handleMenuKeydown: ( store ) => { - const { context, selectors, event } = store; - if ( - selectors.core.navigation.menuOpenedBy( store ).click - ) { - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - return; - } + submenuButtons.forEach( function ( button ) { + button.addEventListener( 'click', toggleSubmenuOnClick ); + } ); - // Trap focus if it is an overlay (main menu). - if ( - context.core.navigation.type === 'overlay' && - event.key === 'Tab' - ) { - // If shift + tab it change the direction. - if ( - event.shiftKey && - window.document.activeElement === - context.core.navigation - .firstFocusableElement - ) { - event.preventDefault(); - context.core.navigation.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.navigation.lastFocusableElement - ) { - event.preventDefault(); - context.core.navigation.firstFocusableElement.focus(); - } - } - } - }, - handleMenuFocusout: ( store ) => { - const { context, event } = store; - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, - // `window.document.activeElement` doesn't change. - if ( - ! context.core.navigation.modal?.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement - ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - } - }, - }, - }, - }, + // 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' + ); + submenuBlocks.forEach( function ( block ) { + if ( ! block.contains( event.target ) ) { + closeSubmenus( block ); + } else if ( event.key === 'Escape' ) { + const toggle = block.querySelector( '[aria-expanded="true"]' ); + closeSubmenus( block ); + // Focus the submenu trigger so focus does not get trapped in the closed submenu. + toggle?.focus(); + } + } ); + } ); } );