Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Enhance navigation with smooth scroll to specific elements #10349

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions assets/js/interactivity/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { useContext, useMemo, useEffect } from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
import { prefetch, navigate, canDoClientSideNavigation } from './router';

// Check if current page can do client-side navigation.
const clientSideNavigation = canDoClientSideNavigation( document.head );
import { prefetch, navigate } from './router';

const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
Expand Down Expand Up @@ -151,40 +148,54 @@ export default () => {
}
);

// data-wc-link
// data-wc-navigation-link
directive(
'link',
'navigation-link',
( {
directives: {
link: { default: link },
'navigation-link': { default: link },
},
props: { href },
element,
} ) => {
useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( clientSideNavigation && link?.prefetch ) {
if ( link?.prefetch ) {
prefetch( href );
}
} );

// Don't do anything if it's falsy.
if ( clientSideNavigation && link !== false ) {
if ( link !== false ) {
element.props.onclick = async ( event ) => {
event.preventDefault();

// Fetch the page (or return it from cache).
await navigate( href );

// Scroll to the element if it's defined.
let topPosition = 0;
if ( link?.scrollToSelector ) {
const element = document.querySelector(
link?.scrollToSelector
);

if ( element ) {
topPosition =
element.getBoundingClientRect().top +
window.scrollY;
}
}

// Update the scroll, depending on the option. True by default.
if ( link?.scroll === 'smooth' ) {
window.scrollTo( {
top: 0,
top: topPosition,
left: 0,
behavior: 'smooth',
} );
} else if ( link?.scroll !== false ) {
window.scrollTo( 0, 0 );
window.scrollTo( 0, topPosition );
}
};
}
Expand Down
187 changes: 61 additions & 126 deletions assets/js/interactivity/router.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { hydrate, render } from 'preact';
import { toVdom, hydratedIslands } from './vdom';
import { createRootFragment } from './utils';
import { csnMetaTagItemprop, directivePrefix } from './constants';
import { directivePrefix } from './constants';

// The root to render the vdom (document.body).
let rootFragment;

// The cache of visited and prefetched pages, stylesheets and scripts.
// The cache of visited and prefetched pages.
const pages = new Map();
const stylesheets = new Map();
const scripts = new Map();

// Keep the same root fragment for each interactive region node.
const regionRootFragments = new WeakMap();
const getRegionRootFragment = ( region ) => {
if ( ! regionRootFragments.has( region ) ) {
regionRootFragments.set(
region,
createRootFragment( region.parentElement, region )
);
}
return regionRootFragments.get( region );
};

// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
Expand All @@ -18,94 +25,32 @@ const cleanUrl = ( url ) => {
return u.pathname + u.search;
};

// Helper to check if a page can do client-side navigation.
export const canDoClientSideNavigation = ( dom ) =>
dom
.querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
?.getAttribute( 'content' ) === 'active';

/**
* Finds the elements in the document that match the selector and fetch them.
* For each element found, fetch the content and store it in the cache.
* Returns an array of elements to add to the document.
*
* @param {Document} document
* @param {string} selector - CSS selector used to find the elements.
* @param {'href'|'src'} attribute - Attribute that determines where to fetch
* the styles or scripts from. Also used as the key for the cache.
* @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`.
* @param {'style'|'script'} elementToCreate - Element to create for each fetched
* item. Can be 'style' or 'script'.
* @return {Promise<Array<HTMLElement>>} - Array of elements to add to the document.
*/
const fetchScriptOrStyle = async (
document,
selector,
attribute,
cache,
elementToCreate
) => {
const fetchedItems = await Promise.all(
[].map.call( document.querySelectorAll( selector ), ( el ) => {
const attributeValue = el.getAttribute( attribute );
if ( ! cache.has( attributeValue ) )
cache.set(
attributeValue,
fetch( attributeValue ).then( ( r ) => r.text() )
);
return cache.get( attributeValue );
} )
);
// Fetch a new page and convert it to a static virtual DOM.
const fetchPage = async ( url ) => {
let dom;
try {
const res = await window.fetch( url );
if ( res.status !== 200 ) return false;
const html = await res.text();
dom = new window.DOMParser().parseFromString( html, 'text/html' );
} catch ( e ) {
return false;
}

return fetchedItems.map( ( item ) => {
const element = document.createElement( elementToCreate );
element.textContent = item;
return element;
} );
return regionsToVdom( dom );
};

// Fetch styles of a new page.
const fetchAssets = async ( document ) => {
const stylesFromSheets = await fetchScriptOrStyle(
document,
'link[rel=stylesheet]',
'href',
stylesheets,
'style'
);
const scriptTags = await fetchScriptOrStyle(
document,
'script[src]',
'src',
scripts,
'script'
);
const moduleScripts = await fetchScriptOrStyle(
document,
'script[type=module]',
'src',
scripts,
'script'
);
moduleScripts.forEach( ( script ) =>
script.setAttribute( 'type', 'module' )
);
// Return an object with VDOM trees of those HTML regions marked with a
// `navigation-id` directive.
const regionsToVdom = ( dom ) => {
const regions = {};
const attrName = `data-${ directivePrefix }-navigation-id`;
dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
const id = region.getAttribute( attrName );
regions[ id ] = toVdom( region );
} );

return [
...scriptTags,
document.querySelector( 'title' ),
...document.querySelectorAll( 'style' ),
...stylesFromSheets,
];
};

// Fetch a new page and convert it to a static virtual DOM.
const fetchPage = async ( url ) => {
const html = await window.fetch( url ).then( ( r ) => r.text() );
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
if ( ! canDoClientSideNavigation( dom.head ) ) return false;
const head = await fetchAssets( dom );
return { head, body: toVdom( dom.body ) };
return { regions };
};

// Prefetch a page. We store the promise to avoid triggering a second fetch for
Expand All @@ -117,14 +62,23 @@ export const prefetch = ( url ) => {
}
};

// Render all interactive regions contained in the given page.
const renderRegions = ( page ) => {
const attrName = `data-${ directivePrefix }-navigation-id`;
document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
const id = region.getAttribute( attrName );
const fragment = getRegionRootFragment( region );
render( page.regions[ id ], fragment );
} );
};

// Navigate to a new page.
export const navigate = async ( href, { replace = false } = {} ) => {
const url = cleanUrl( href );
prefetch( url );
const page = await pages.get( url );
if ( page ) {
document.head.replaceChildren( ...page.head );
render( page.body, rootFragment );
renderRegions( page );
window.history[ replace ? 'replaceState' : 'pushState' ](
{},
'',
Expand All @@ -141,46 +95,27 @@ window.addEventListener( 'popstate', async () => {
const url = cleanUrl( window.location ); // Remove hash.
const page = pages.has( url ) && ( await pages.get( url ) );
if ( page ) {
document.head.replaceChildren( ...page.head );
render( page.body, rootFragment );
renderRegions( page );
} else {
window.location.reload();
}
} );

// Initialize the router with the initial DOM.
export const init = async () => {
if ( canDoClientSideNavigation( document.head ) ) {
// Create the root fragment to hydrate everything.
rootFragment = createRootFragment(
document.documentElement,
document.body
);
const body = toVdom( document.body );
hydrate( body, rootFragment );

// Cache the scripts. Has to be called before fetching the assets.
[].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => {
scripts.set( script.getAttribute( 'src' ), script.textContent );
document
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
.forEach( ( node ) => {
if ( ! hydratedIslands.has( node ) ) {
const fragment = getRegionRootFragment( node );
const vdom = toVdom( node );
hydrate( vdom, fragment );
}
} );

const head = await fetchAssets( document );
pages.set(
cleanUrl( window.location ),
Promise.resolve( { body, head } )
);
} else {
document
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
.forEach( ( node ) => {
if ( ! hydratedIslands.has( node ) ) {
const fragment = createRootFragment(
node.parentNode,
node
);
const vdom = toVdom( node );
hydrate( vdom, fragment );
}
} );
}
// Cache the current regions.
pages.set(
cleanUrl( window.location ),
Promise.resolve( regionsToVdom( document ) )
);
};
77 changes: 77 additions & 0 deletions src/BlockTypes/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,83 @@ protected function initialize() {
);
add_filter( 'rest_product_query', array( $this, 'update_rest_query' ), 10, 2 );
add_filter( 'rest_product_collection_params', array( $this, 'extend_rest_query_allowed_params' ), 10, 1 );
add_filter( 'render_block_core/query', array( $this, 'add_navigation_id_directive' ), 10, 3 );
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
}

/**
* Mark the Product Query as an interactive region so it can be updated
* during client-side navigation.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param \WP_Block $instance The block instance.
*/
public function add_navigation_id_directive( $block_content, $block, $instance ) {
if ( self::is_woocommerce_variation( $block ) ) {
// Enqueue the Interactivity API runtime.
wp_enqueue_script( 'wc-interactivity' );

$p = new \WP_HTML_Tag_Processor( $block_content );

// Add `data-wc-navigation-id to the query block.
if ( $p->next_tag( array( 'class_name' => 'wp-block-query' ) ) ) {
$p->set_attribute(
'data-wc-navigation-id',
'woo-products-' . $block['attrs']['queryId']
);
$p->set_attribute( 'data-wc-interactive', true );
$block_content = $p->get_updated_html();
}
}

return $block_content;
}

/**
* Add interactive links to all anchors inside the Query Pagination block.
*
* @param string $block_content The block content.
* @param array $block The full block, including name and attributes.
* @param \WP_Block $instance The block instance.
*/
public function add_navigation_link_directives( $block_content, $block, $instance ) {
if (
self::is_woocommerce_variation( $this->parsed_block ) &&
$instance->context['queryId'] === $this->parsed_block['attrs']['queryId']
) {
$p = new \WP_HTML_Tag_Processor( $block_content );
$p->next_tag( array( 'class_name' => 'wp-block-query-pagination' ) );

while ( $p->next_tag( 'a' ) ) {
$class_attr = $p->get_attribute( 'class' );
$class_list = preg_split( '/\s+/', $class_attr );

$is_previous = in_array( 'wp-block-query-pagination-previous', $class_list, true );
$is_next = in_array( 'wp-block-query-pagination-next', $class_list, true );
$is_previous_or_next = $is_previous || $is_next;

$navigation_link_payload = array(
'prefetch' => $is_previous_or_next,
'scroll' => true,
'scrollToSelector' => '[data-wc-navigation-id="woo-products-' . $this->parsed_block['attrs']['queryId'] . '"]',
);

$p->set_attribute(
'data-wc-navigation-link',
wp_json_encode( $navigation_link_payload )
);

if ( $is_previous ) {
$p->set_attribute( 'key', 'pagination-previous' );
} elseif ( $is_next ) {
$p->set_attribute( 'key', 'pagination-next' );
}
}
$block_content = $p->get_updated_html();
}

return $block_content;
}

/**
Expand Down
Loading
Loading