-
-
-
- %2$s
-
+ '
+
+
',
esc_attr( $modal_unique_id ),
$inner_blocks_html,
$toggle_aria_label_open,
@@ -656,7 +680,7 @@ function render_block_core_navigation( $attributes, $content, $block ) {
);
return sprintf(
- '
',
+ '
',
$wrapper_attributes,
$responsive_container_markup
);
diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js
deleted file mode 100644
index 477f05b5d5e51..0000000000000
--- a/packages/block-library/src/navigation/view-modal.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * 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' );
-}
-
-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 (
- ! link.getAttribute( 'href' )?.startsWith( '#' ) ||
- 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 19805a44ae4ae..916732f6dedea 100644
--- a/packages/block-library/src/navigation/view.js
+++ b/packages/block-library/src/navigation/view.js
@@ -1,74 +1,34 @@
-// Open on click functionality.
-function closeSubmenus( element ) {
- element
- .querySelectorAll( '[aria-expanded="true"]' )
- .forEach( function ( toggle ) {
- toggle.setAttribute( 'aria-expanded', 'false' );
- } );
-}
+/**
+ * Internal dependencies
+ */
+import wpx from './wpx';
-function toggleSubmenuOnClick( event ) {
- const buttonToggle = event.target.closest( '[aria-expanded]' );
- const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' );
-
- 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 );
+wpx( {
+ core: {
+ navigation: {
+ openMenu: ( { context: { navigation } } ) => {
+ navigation.open = true;
+ navigation.previousElementWithFocus =
+ window.document.activeElement;
+ },
+ closeMenu: ( { context: { navigation } } ) => {
+ navigation.open = false;
+ },
+ isMenuOpen: ( { context: { navigation } } ) => navigation.open,
+ isMenuClosed: ( { context: { navigation } } ) => ! navigation.open,
+ addFirstElementToContext: ( { context: { navigation }, ref } ) => {
+ navigation.firstMenuElement = ref;
+ },
+ focusFirstElement: async ( { context: { navigation }, tick } ) => {
+ if ( navigation.open ) {
+ await tick(); // We need to wait until the DOM is updated.
+ navigation.firstMenuElement.focus();
}
- } );
- // Open submenu.
- buttonToggle.setAttribute( 'aria-expanded', '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'
- );
-
- submenuButtons.forEach( function ( button ) {
- button.addEventListener( 'click', toggleSubmenuOnClick );
- } );
-
- // 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();
- }
- } );
- } );
+ },
+ focusLastFocusedElement: ( { context: { navigation } } ) => {
+ if ( ! navigation.open && navigation.previousElementWithFocus )
+ navigation.previousElementWithFocus.focus();
+ },
+ },
+ },
} );
diff --git a/packages/block-library/src/navigation/wpx/components.js b/packages/block-library/src/navigation/wpx/components.js
new file mode 100644
index 0000000000000..3cfc473775414
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/components.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line eslint-comments/disable-enable-pair
+/* eslint-disable jsdoc/check-tag-names */
+/** @jsx h */
+
+/**
+ * External dependencies
+ */
+import { h } from 'preact';
+import { useMemo } from 'preact/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { deepSignal } from './deep-signal';
+import { component } from './hooks';
+
+export default () => {
+ const WpContext = ( { children, data, context: { Provider } } ) => {
+ const signals = useMemo( () => deepSignal( JSON.parse( data ) ), [] );
+ return
{ children };
+ };
+ component( 'wp-context', WpContext );
+};
diff --git a/packages/block-library/src/navigation/wpx/deep-signal.js b/packages/block-library/src/navigation/wpx/deep-signal.js
new file mode 100644
index 0000000000000..707cb56da79dc
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/deep-signal.js
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { signal } from '@preact/signals';
+
+/**
+ * Internal dependencies
+ */
+import { knownSymbols, shouldWrap } from './utils';
+
+const proxyToSignals = new WeakMap();
+const objToProxy = new WeakMap();
+
+export const deepSignal = ( obj ) => new Proxy( obj, handlers );
+export const options = { returnSignal: /^\$/ };
+
+const handlers = {
+ get( target, prop, receiver ) {
+ if ( typeof prop === 'symbol' && knownSymbols.has( prop ) )
+ return Reflect.get( target, prop, receiver );
+ const returnSignal = options.returnSignal.test( prop );
+ const key = returnSignal
+ ? prop.replace( options.returnSignal, '' )
+ : prop;
+ if ( ! proxyToSignals.has( receiver ) )
+ proxyToSignals.set( receiver, new Map() );
+ const signals = proxyToSignals.get( receiver );
+ if ( ! signals.has( key ) ) {
+ let val = Reflect.get( target, key, receiver );
+ if ( typeof val === 'object' && val !== null && shouldWrap( val ) )
+ val = new Proxy( val, handlers );
+ signals.set( key, signal( val ) );
+ }
+ return returnSignal ? signals.get( key ) : signals.get( key ).value;
+ },
+
+ set( target, prop, val, receiver ) {
+ let internal = val;
+ if ( typeof val === 'object' && val !== null && shouldWrap( val ) ) {
+ if ( ! objToProxy.has( val ) )
+ objToProxy.set( val, new Proxy( val, handlers ) );
+ internal = objToProxy.get( val );
+ }
+ if ( ! proxyToSignals.has( receiver ) )
+ proxyToSignals.set( receiver, new Map() );
+ const signals = proxyToSignals.get( receiver );
+ if ( ! signals.has( prop ) ) signals.set( prop, signal( internal ) );
+ else signals.get( prop ).value = internal;
+ return Reflect.set( target, prop, val, receiver );
+ },
+};
diff --git a/packages/block-library/src/navigation/wpx/directives.js b/packages/block-library/src/navigation/wpx/directives.js
new file mode 100644
index 0000000000000..3f90f03520e48
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/directives.js
@@ -0,0 +1,102 @@
+// eslint-disable-next-line eslint-comments/disable-enable-pair
+/* eslint-disable jsdoc/check-tag-names */
+// eslint-disable-next-line eslint-comments/disable-enable-pair
+/* eslint-disable no-eval */
+
+/** @jsx h */
+
+/**
+ * External dependencies
+ */
+import { h } from 'preact';
+import { useContext, useMemo } from 'preact/hooks';
+import { useSignalEffect } from '@preact/signals';
+
+/**
+ * Internal dependencies
+ */
+import { directive } from './hooks';
+import { deepSignal } from './deep-signal';
+import { getCallback } from './utils';
+
+const raf = window.requestAnimationFrame;
+// Until useSignalEffects is fixed: https://github.com/preactjs/signals/issues/228
+const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) );
+
+export default () => {
+ // wp-context
+ directive(
+ 'context',
+ ( {
+ directives: { context },
+ props: { children },
+ context: { Provider },
+ } ) => {
+ const signals = useMemo( () => deepSignal( context.default ), [] );
+ return
{ children };
+ }
+ );
+
+ // wp-effect
+ directive(
+ 'effect',
+ ( { directives: { effect }, element, context: mainContext } ) => {
+ const context = useContext( mainContext );
+ Object.values( effect ).forEach( ( callback ) => {
+ useSignalEffect( () => {
+ const cb = getCallback( callback );
+ cb( { context, tick, ref: element.ref.current } );
+ } );
+ } );
+ }
+ );
+
+ // wp-on:[event]
+ directive(
+ 'on',
+ ( { directives: { on }, element, context: mainContext } ) => {
+ const context = useContext( mainContext );
+ Object.entries( on ).forEach( ( [ name, callback ] ) => {
+ element.props[ `on${ name }` ] = ( event ) => {
+ const cb = getCallback( callback );
+ cb( { context, event } );
+ };
+ } );
+ }
+ );
+
+ // wp-class:[classname]
+ directive(
+ 'class',
+ ( {
+ directives: { class: className },
+ element,
+ context: mainContext,
+ } ) => {
+ const context = useContext( mainContext );
+ Object.keys( className )
+ .filter( ( n ) => n !== 'default' )
+ .forEach( ( name ) => {
+ const cb = getCallback( className[ name ] );
+ const result = cb( { context } );
+ if ( ! result ) element.props.class.replace( name, '' );
+ else if ( ! element.props.class.includes( name ) )
+ element.props.class += ` ${ name }`;
+ } );
+ }
+ );
+
+ // wp-bind:[attribute]
+ directive(
+ 'bind',
+ ( { directives: { bind }, element, context: mainContext } ) => {
+ const context = useContext( mainContext );
+ Object.entries( bind )
+ .filter( ( n ) => n !== 'default' )
+ .forEach( ( [ attribute, callback ] ) => {
+ const cb = getCallback( callback );
+ element.props[ attribute ] = cb( { context } );
+ } );
+ }
+ );
+};
diff --git a/packages/block-library/src/navigation/wpx/hooks.js b/packages/block-library/src/navigation/wpx/hooks.js
new file mode 100644
index 0000000000000..72e4429d4af44
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/hooks.js
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { h, options, createContext } from 'preact';
+import { useRef } from 'preact/hooks';
+
+// Main context
+const context = createContext( {} );
+
+// WordPress Directives.
+const directives = {};
+export const directive = ( name, cb ) => {
+ directives[ name ] = cb;
+};
+
+// WordPress Components.
+const components = {};
+export const component = ( name, Comp ) => {
+ components[ name ] = Comp;
+};
+
+// Directive wrapper.
+const WpDirective = ( { type, wp, props: originalProps } ) => {
+ const ref = useRef( null );
+ const element = h( type, { ...originalProps, ref, _wrapped: true } );
+ const props = { ...originalProps, children: element };
+ const directiveArgs = { directives: wp, props, element, context };
+
+ for ( const d in wp ) {
+ const wrapper = directives[ d ]?.( directiveArgs );
+ if ( wrapper !== undefined ) props.children = wrapper;
+ }
+
+ return props.children;
+};
+
+// Preact Options Hook called each time a vnode is created.
+const old = options.vnode;
+options.vnode = ( vnode ) => {
+ const type = vnode.type;
+ const wp = vnode.props.wp;
+
+ if ( typeof type === 'string' && type.startsWith( 'wp-' ) ) {
+ vnode.type = components[ type ];
+ vnode.props.context = context;
+ }
+
+ if ( wp ) {
+ const props = vnode.props;
+ delete props.wp;
+ if ( ! props._wrapped ) {
+ vnode.props = { type: vnode.type, wp, props };
+ vnode.type = WpDirective;
+ } else {
+ delete props._wrapped;
+ }
+ }
+
+ if ( old ) old( vnode );
+};
diff --git a/packages/block-library/src/navigation/wpx/index.js b/packages/block-library/src/navigation/wpx/index.js
new file mode 100644
index 0000000000000..f09b2c787c207
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/index.js
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { hydrate } from 'preact';
+
+/**
+ * Internal dependencies
+ */
+import registerDirectives from './directives';
+import registerComponents from './components';
+import toVdom from './vdom';
+import { createRootFragment, idle, deepMerge } from './utils';
+
+/**
+ * Initialize the initial vDOM.
+ */
+document.addEventListener( 'DOMContentLoaded', async () => {
+ registerDirectives();
+ registerComponents();
+
+ // Create the root fragment to hydrate everything.
+ const rootFragment = createRootFragment(
+ document.documentElement,
+ document.body
+ );
+
+ await idle(); // Wait until the CPU is idle to do the hydration.
+ const vdom = toVdom( document.body );
+ hydrate( vdom, rootFragment );
+
+ // eslint-disable-next-line no-console
+ console.log( 'hydrated!' );
+} );
+
+window.wpx = window.wpx || {};
+
+export default ( block ) => {
+ deepMerge( window.wpx, block );
+};
diff --git a/packages/block-library/src/navigation/wpx/utils.js b/packages/block-library/src/navigation/wpx/utils.js
new file mode 100644
index 0000000000000..89875a093aed3
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/utils.js
@@ -0,0 +1,77 @@
+// eslint-disable-next-line eslint-comments/disable-enable-pair
+/* eslint-disable no-undef */
+
+// For wrapperless hydration of document.body.
+// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
+export const createRootFragment = ( parent, replaceNode ) => {
+ replaceNode = [].concat( replaceNode );
+ const s = replaceNode[ replaceNode.length - 1 ].nextSibling;
+ function insert( c, r ) {
+ parent.insertBefore( c, r || s );
+ }
+ return ( parent.__k = {
+ nodeType: 1,
+ parentNode: parent,
+ firstChild: replaceNode[ 0 ],
+ childNodes: replaceNode,
+ insertBefore: insert,
+ appendChild: insert,
+ removeChild( c ) {
+ parent.removeChild( c );
+ },
+ } );
+};
+
+// Helper function to await until the CPU is idle.
+export const idle = () =>
+ new Promise( ( resolve ) => window.requestIdleCallback( resolve ) );
+
+export const knownSymbols = new Set(
+ Object.getOwnPropertyNames( Symbol )
+ .map( ( key ) => Symbol[ key ] )
+ .filter( ( value ) => typeof value === 'symbol' )
+);
+const supported = new Set( [
+ Object,
+ Array,
+ Int8Array,
+ Uint8Array,
+ Uint8ClampedArray,
+ Int16Array,
+ Uint16Array,
+ Int32Array,
+ Uint32Array,
+ Float32Array,
+ Float64Array,
+] );
+export const shouldWrap = ( { constructor } ) => {
+ const isBuiltIn =
+ typeof constructor === 'function' &&
+ constructor.name in globalThis &&
+ globalThis[ constructor.name ] === constructor;
+ return ! isBuiltIn || supported.has( constructor );
+};
+
+// Deep Merge
+const isObject = ( item ) =>
+ item && typeof item === 'object' && ! Array.isArray( item );
+
+export const deepMerge = ( target, source ) => {
+ if ( isObject( target ) && isObject( source ) ) {
+ for ( const key in source ) {
+ if ( isObject( source[ key ] ) ) {
+ if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } );
+ deepMerge( target[ key ], source[ key ] );
+ } else {
+ Object.assign( target, { [ key ]: source[ key ] } );
+ }
+ }
+ }
+};
+
+// Get callback.
+export const getCallback = ( path ) => {
+ let current = window.wpx;
+ path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
+ return current;
+};
diff --git a/packages/block-library/src/navigation/wpx/vdom.js b/packages/block-library/src/navigation/wpx/vdom.js
new file mode 100644
index 0000000000000..11940245b6621
--- /dev/null
+++ b/packages/block-library/src/navigation/wpx/vdom.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { h } from 'preact';
+
+// Recursive function that transfoms a DOM tree into vDOM.
+export default function toVdom( node ) {
+ const props = {};
+ const attributes = node.attributes;
+ const wpDirectives = {};
+ let hasWpDirectives = false;
+
+ if ( node.nodeType === 3 ) return node.data;
+ if ( node.nodeType === 8 ) return null;
+ if ( node.localName === 'script' ) return h( 'script' );
+
+ for ( let i = 0; i < attributes.length; i++ ) {
+ const name = attributes[ i ].name;
+ if ( name.startsWith( 'wp-' ) ) {
+ hasWpDirectives = true;
+ let val = attributes[ i ].value;
+ try {
+ val = JSON.parse( val );
+ } catch ( e ) {}
+ const [ , prefix, suffix ] = /wp-([^:]+):?(.*)$/.exec( name );
+ wpDirectives[ prefix ] = wpDirectives[ prefix ] || {};
+ wpDirectives[ prefix ][ suffix || 'default' ] = val;
+ } else {
+ props[ name ] = attributes[ i ].value;
+ }
+ }
+
+ if ( hasWpDirectives ) props.wp = wpDirectives;
+
+ // Walk child nodes and return vDOM children.
+ const children = [].map.call( node.childNodes, toVdom ).filter( exists );
+
+ return h( node.localName, props, children );
+}
+
+// Filter existing items.
+const exists = ( x ) => x;