From fd68223df5094d8ea54d3493d1f68f88682ec50b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Fri, 2 Feb 2024 17:25:12 +0100 Subject: [PATCH] Interactivity Router: Move ARIA live region and loading bar to the Interactivity Router (#58377) * Expose state related to navigations * Remove loading bar and aria region from query block * Implement wp-router-region processor * Remove aria regions and loading bar logic from query block * Move loading bar CSS from query to router region processor * Recover `navigatingTo` variable * Fix flaky test * Remove unnecessary PHPUnit checks * Ensure the callback is executed once * Update boolean flags and message only if page exists * Clarify usage of unresolved promise * More code reordering * Save current link URL after navigating * Add topLoadingBar and screenReaderAnnounce options * Fix url updating after navigating back or forward * Rename internal `url` variable to `pagePath` * Always set a string in `state.url` * Remove confusing comment * Use internal state instance instead of `wp_interactivity_state` * Add id to the router animations style tag * Test the `data-wp-router-region` directive processor * Rename topLoadingBar option to loadingAnimation * Update docs for options.loadingAnimation * Move router-region flag to the WP_Interactivity_API class * Fix screenReaderAnnouncement name --- .../class-wp-interactivity-api.php | 110 ++++++++++++- packages/block-library/src/query/block.json | 3 +- packages/block-library/src/query/index.php | 37 +---- packages/block-library/src/query/style.scss | 52 ------- packages/block-library/src/query/view.js | 26 ---- packages/interactivity-router/src/index.js | 108 +++++++++---- phpunit/blocks/render-query-test.php | 53 +------ ...nteractivity-api-wp-router-region-test.php | 147 ++++++++++++++++++ 8 files changed, 334 insertions(+), 202 deletions(-) delete mode 100644 packages/block-library/src/query/style.scss create mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-router-region-test.php diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php index ad9e5d7c43953..9cbbfb1d6b654 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -18,12 +18,13 @@ class WP_Interactivity_API { * @var array */ private static $directive_processors = array( - 'data-wp-interactive' => 'data_wp_interactive_processor', - 'data-wp-context' => 'data_wp_context_processor', - 'data-wp-bind' => 'data_wp_bind_processor', - 'data-wp-class' => 'data_wp_class_processor', - 'data-wp-style' => 'data_wp_style_processor', - 'data-wp-text' => 'data_wp_text_processor', + 'data-wp-interactive' => 'data_wp_interactive_processor', + 'data-wp-router-region' => 'data_wp_router_region_processor', + 'data-wp-context' => 'data_wp_context_processor', + 'data-wp-bind' => 'data_wp_bind_processor', + 'data-wp-class' => 'data_wp_class_processor', + 'data-wp-style' => 'data_wp_style_processor', + 'data-wp-text' => 'data_wp_text_processor', ); /** @@ -49,6 +50,21 @@ class WP_Interactivity_API { */ private $config_data = array(); + /** + * Flag that indicates whether the `data-wp-router-region` directive has + * been found in the HTML and processed. + * + * The value is saved in a private property of the WP_Interactivity_API + * instance instead of using a static variable inside the processor + * function, which would hold the same value for all instances + * independently of whether they have processed any + * `data-wp-router-region` directive or not. + * + * @since 6.5.0 + * @var bool + */ + private $has_processed_router_region = false; + /** * Gets and/or sets the initial state of an Interactivity API store for a * given namespace. @@ -673,6 +689,88 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process } } } + + /** + * Processes the `data-wp-router-region` directive. + * + * It renders in the footer a set of HTML elements to notify users about + * client-side navigations. More concretely, the elements added are 1) a + * top loading bar to visually inform that a navigation is in progress + * and 2) an `aria-live` region for accessible navigation announcements. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + */ + private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p ) { + if ( ! $p->is_tag_closer() && ! $this->has_processed_router_region ) { + $this->has_processed_router_region = true; + + // Initialize the `core/router` store. + $this->state( + 'core/router', + array( + 'navigation' => array( + 'message' => '', + 'hasStarted' => false, + 'hasFinished' => false, + 'texts' => array( + 'loading' => __( 'Loading page, please wait.' ), + 'loaded' => __( 'Page Loaded.' ), + ), + ), + ) + ); + + $callback = static function () { + echo << +.wp-interactivity-router_loading-bar { + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; + width: 100vw; + max-width: 100vw !important; + height: 4px; + background-color: var(--wp--preset--color--primary, #000); + opacity: 0 +} +.wp-interactivity-router_loading-bar.start-animation { + animation: wp-interactivity-router_loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards +} +.wp-interactivity-router_loading-bar.finish-animation { + animation: wp-interactivity-router_loading-bar-finish-animation 300ms ease-in +} + +@keyframes wp-interactivity-router_loading-bar-start-animation { + 0% { transform: scaleX(0); transform-origin: 0% 0%; opacity: 1 } + 100% { transform: scaleX(1); transform-origin: 0% 0%; opacity: 1 } +} +@keyframes wp-interactivity-router_loading-bar-finish-animation { + 0% { opacity: 1 } + 50% { opacity: 1 } + 100% { opacity: 0 } +} + +
+
+HTML; + }; + add_action( 'wp_footer', $callback ); + } + } } } diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json index 26d194dcd1f23..8ea8d81843382 100644 --- a/packages/block-library/src/query/block.json +++ b/packages/block-library/src/query/block.json @@ -52,6 +52,5 @@ "html": false, "layout": true }, - "editorStyle": "wp-block-query-editor", - "style": "wp-block-query" + "editorStyle": "wp-block-query-editor" } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 35b3538a72561..b92a85012415b 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -30,43 +30,8 @@ function render_block_core_query( $attributes, $content, $block ) { $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/query"}' ); $p->set_attribute( 'data-wp-router-region', 'query-' . $attributes['queryId'] ); $p->set_attribute( 'data-wp-init', 'callbacks.setQueryRef' ); - // Use context to send translated strings. - $p->set_attribute( - 'data-wp-context', - wp_json_encode( - array( - 'loadingText' => __( 'Loading page, please wait.' ), - 'loadedText' => __( 'Page Loaded.' ), - ), - JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP - ) - ); + $p->set_attribute( 'data-wp-context', '{}' ); $content = $p->get_updated_html(); - - // Mark the block as interactive. - $block->block_type->supports['interactivity'] = true; - - // Add a div to announce messages using `aria-live`. - $html_tag = 'div'; - if ( ! empty( $attributes['tagName'] ) ) { - $html_tag = esc_attr( $attributes['tagName'] ); - } - $last_tag_position = strripos( $content, '' ); - $content = substr_replace( - $content, - '
-
', - $last_tag_position, - 0 - ); } } diff --git a/packages/block-library/src/query/style.scss b/packages/block-library/src/query/style.scss deleted file mode 100644 index 4e9f4741beaed..0000000000000 --- a/packages/block-library/src/query/style.scss +++ /dev/null @@ -1,52 +0,0 @@ -.wp-block-query__enhanced-pagination-animation { - position: fixed; - top: 0; - left: 0; - margin: 0; - padding: 0; - width: 100vw; - max-width: 100vw !important; - height: 4px; - background-color: var(--wp--preset--color--primary, #000); - opacity: 0; - - &.start-animation { - animation: - wp-block-query__enhanced-pagination-start-animation - 30s - cubic-bezier(0.03, 0.5, 0, 1) - forwards; - } - - &.finish-animation { - animation: - wp-block-query__enhanced-pagination-finish-animation - 300ms - ease-in; - } -} - -@keyframes wp-block-query__enhanced-pagination-start-animation { - 0% { - transform: scaleX(0); - transform-origin: 0% 0%; - opacity: 1; - } - 100% { - transform: scaleX(1); - transform-origin: 0% 0%; - opacity: 1; - } -} - -@keyframes wp-block-query__enhanced-pagination-finish-animation { - 0% { - opacity: 1; - } - 50% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 85cd16d9e1422..dc82d7968dad4 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -19,14 +19,6 @@ const isValidEvent = ( event ) => ! event.defaultPrevented; store( 'core/query', { - state: { - get startAnimation() { - return getContext().animation === 'start'; - }, - get finishAnimation() { - return getContext().animation === 'finish'; - }, - }, actions: { *navigate( event ) { const ctx = getContext(); @@ -37,28 +29,10 @@ store( 'core/query', { if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { event.preventDefault(); - // Don't announce the navigation immediately, wait 400 ms. - const timeout = setTimeout( () => { - ctx.message = ctx.loadingText; - ctx.animation = 'start'; - }, 400 ); - const { actions } = yield import( '@wordpress/interactivity-router' ); yield actions.navigate( ref.href ); - - // Dismiss loading message if it hasn't been added yet. - clearTimeout( timeout ); - - // Announce that the page has been loaded. If the message is the - // same, we use a no-break space similar to the @wordpress/a11y - // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 - ctx.message = - ctx.loadedText + - ( ctx.message === ctx.loadedText ? '\u00A0' : '' ); - - ctx.animation = 'finish'; ctx.url = ref.href; // Focus the first anchor of the Query block. diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 7396c21e9638d..a985ed3d74b89 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -14,7 +14,7 @@ const pages = new Map(); // Helper to remove domain and hash from the URL. We are only interesting in // caching the path and the query. -const cleanUrl = ( url ) => { +const getPagePath = ( url ) => { const u = new URL( url, window.location ); return u.pathname + u.search; }; @@ -60,16 +60,15 @@ const renderRegions = ( page ) => { } }; -// Variable to store the current navigation. -let navigatingTo = ''; - // Listen to the back and forward buttons and restore the page if it's in the // cache. window.addEventListener( 'popstate', async () => { - const url = cleanUrl( window.location ); // Remove hash. - const page = pages.has( url ) && ( await pages.get( url ) ); + const pagePath = getPagePath( window.location ); // Remove hash. + const page = pages.has( pagePath ) && ( await pages.get( pagePath ) ); if ( page ) { renderRegions( page ); + // Update the URL in the state. + state.url = window.location.href; } else { window.location.reload(); } @@ -77,11 +76,22 @@ window.addEventListener( 'popstate', async () => { // Cache the current regions. pages.set( - cleanUrl( window.location ), + getPagePath( window.location ), Promise.resolve( regionsToVdom( document ) ) ); +// Variable to store the current navigation. +let navigatingTo = ''; + export const { state, actions } = store( 'core/router', { + state: { + url: window.location.href, + navigation: { + hasStarted: false, + hasFinished: false, + texts: {}, + }, + }, actions: { /** * Navigates to the specified page. @@ -90,38 +100,56 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {string} href The page href. - * @param {Object} [options] Options object. - * @param {boolean} [options.force] If true, it forces re-fetching the - * URL. - * @param {string} [options.html] HTML string to be used instead of - * fetching the requested URL. - * @param {boolean} [options.replace] If true, it replaces the current - * entry in the browser session - * history. - * @param {number} [options.timeout] Time until the navigation is - * aborted, in milliseconds. Default - * is 10000. + * @param {string} href The page href. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. + * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. + * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. * - * @return {Promise} Promise that resolves once the navigation is - * completed or aborted. + * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ *navigate( href, options = {} ) { - const url = cleanUrl( href ); + const pagePath = getPagePath( href ); + const { navigation } = state; + const { + loadingAnimation = true, + screenReaderAnnouncement = true, + timeout = 10000, + } = options; + navigatingTo = href; - actions.prefetch( url, options ); + actions.prefetch( pagePath, options ); // Create a promise that resolves when the specified timeout ends. // The timeout value is 10 seconds by default. const timeoutPromise = new Promise( ( resolve ) => - setTimeout( resolve, options.timeout ?? 10000 ) + setTimeout( resolve, timeout ) ); + // Don't update the navigation status immediately, wait 400 ms. + const loadingTimeout = setTimeout( () => { + if ( navigatingTo !== href ) return; + + if ( loadingAnimation ) { + navigation.hasStarted = true; + navigation.hasFinished = false; + } + if ( screenReaderAnnouncement ) { + navigation.message = navigation.texts.loading; + } + }, 400 ); + const page = yield Promise.race( [ - pages.get( url ), + pages.get( pagePath ), timeoutPromise, ] ); + // Dismiss loading message if it hasn't been added yet. + clearTimeout( loadingTimeout ); + // Once the page is fetched, the destination URL could have changed // (e.g., by clicking another link in the meantime). If so, bail // out, and let the newer execution to update the HTML. @@ -132,8 +160,32 @@ export const { state, actions } = store( 'core/router', { window.history[ options.replace ? 'replaceState' : 'pushState' ]( {}, '', href ); + + // Update the URL in the state. + state.url = href; + + // Update the navigation status once the the new page rendering + // has been completed. + if ( loadingAnimation ) { + navigation.hasStarted = false; + navigation.hasFinished = true; + } + + if ( screenReaderAnnouncement ) { + // Announce that the page has been loaded. If the message is the + // same, we use a no-break space similar to the @wordpress/a11y + // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 + navigation.message = + navigation.texts.loaded + + ( navigation.message === navigation.texts.loaded + ? '\u00A0' + : '' ); + } } else { window.location.assign( href ); + // Await a promise that won't resolve to prevent any potential + // feedback indicating that the navigation has finished while + // the new page is being loaded. yield new Promise( () => {} ); } }, @@ -151,9 +203,9 @@ export const { state, actions } = store( 'core/router', { * fetching the requested URL. */ prefetch( url, options = {} ) { - url = cleanUrl( url ); - if ( options.force || ! pages.has( url ) ) { - pages.set( url, fetchPage( url, options ) ); + const pagePath = getPagePath( url ); + if ( options.force || ! pages.has( pagePath ) ) { + pages.set( pagePath, fetchPage( pagePath, options ) ); } }, }, diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index 76dbf537e7868..c75174c741785 100644 --- a/phpunit/blocks/render-query-test.php +++ b/phpunit/blocks/render-query-test.php @@ -68,7 +68,7 @@ public function test_rendering_query_with_enhanced_pagination() { $p = new WP_HTML_Tag_Processor( $output ); $p->next_tag( array( 'class_name' => 'wp-block-query' ) ); - $this->assertSame( '{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}', $p->get_attribute( 'data-wp-context' ) ); + $this->assertSame( '{}', $p->get_attribute( 'data-wp-context' ) ); $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) ); $this->assertSame( '{"namespace":"core/query"}', $p->get_attribute( 'data-wp-interactive' ) ); @@ -86,14 +86,6 @@ public function test_rendering_query_with_enhanced_pagination() { $this->assertSame( 'core/query::actions.navigate', $p->get_attribute( 'data-wp-on--click' ) ); $this->assertSame( 'core/query::actions.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) ); $this->assertSame( 'core/query::callbacks.prefetch', $p->get_attribute( 'data-wp-watch' ) ); - - $p->next_tag( array( 'class_name' => 'screen-reader-text' ) ); - $this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) ); - $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) ); - - $p->next_tag( array( 'class_name' => 'wp-block-query__enhanced-pagination-animation' ) ); - $this->assertSame( 'state.startAnimation', $p->get_attribute( 'data-wp-class--start-animation' ) ); - $this->assertSame( 'state.finishAnimation', $p->get_attribute( 'data-wp-class--finish-animation' ) ); } /** @@ -131,49 +123,6 @@ public function test_rendering_query_with_enhanced_pagination_auto_disabled_when $this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) ); } - - /** - * Tests that the `core/query` last tag is rendered with the tagName attribute - * if is defined, having a div as default. - */ - public function test_enhanced_query_markup_rendering_at_bottom_on_custom_html_element_tags() { - global $wp_query, $wp_the_query; - - $content = << - - - -HTML; - - // Set main query to single post. - $wp_query = new WP_Query( - array( - 'posts_per_page' => 1, - ) - ); - - $wp_the_query = $wp_query; - - $output = do_blocks( $content ); - - $p = new WP_HTML_Tag_Processor( $output ); - - $p->next_tag( 'span' ); - - // Test that there is a div added just after the last tag inside the aside. - $this->assertSame( $p->next_tag(), true ); - // Test that that div is the accesibility one. - $this->assertSame( 'screen-reader-text', $p->get_attribute( 'class' ) ); - $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) ); - $this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) ); - } - /** * Tests that the `core/query` block adds an extra attribute to disable the * enhanced pagination in the browser when a post content block is found inside. diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-router-region-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-router-region-test.php new file mode 100644 index 0000000000000..27fa7c1edd1e7 --- /dev/null +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-router-region-test.php @@ -0,0 +1,147 @@ +interactivity = new WP_Interactivity_API(); + + // Remove all hooks set for `wp_footer`. + global $wp_filter; + $this->original_wp_footer = $wp_filter['wp_footer']; + $wp_filter['wp_footer'] = new WP_Hook(); + } + + /** + * Tear down. + */ + public function tear_down() { + // Restore all previous hooks set for `wp_footer`. + global $wp_filter; + $wp_filter['wp_footer'] = $this->original_wp_footer; + + parent::tear_down(); + } + + /** + * Helper to execute the hooks associated to `wp_footer`. + */ + protected function render_wp_footer() { + ob_start(); + do_action( 'wp_footer' ); + return ob_get_clean(); + } + + /** + * Tests that no elements are added if the `data-wp-router-region` is + * missing. + */ + public function test_wp_router_region_missing() { + $html = '
Nothing here
'; + $new_html = $this->interactivity->process_directives( $html ); + $footer = $this->render_wp_footer(); + $this->assertSame( $html, $new_html ); + $this->assertSame( '', $footer ); + } + + /** + * Tests that the `data-wp-router-region` adds a loading bar and a + * region for screen reader announcements in the footer. + */ + public function test_wp_router_region_adds_loading_bar_aria_live_region() { + $html = '
Interactive region
'; + $new_html = $this->interactivity->process_directives( $html ); + $footer = $this->render_wp_footer(); + + $this->assertSame( $html, $new_html ); + + $query = array( 'tag_name' => 'style' ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertSame( 'wp-interactivity-router_animations', $p->get_attribute( 'id' ) ); + $this->assertFalse( $p->next_tag( $query ) ); + + $query = array( + 'tag_name' => 'div', + 'class_name' => 'wp-interactivity-router_loading-bar', + ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + + $query = array( + 'tag_name' => 'div', + 'class_name' => 'screen-reader-text', + ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + } + + /** + * Tests that the `data-wp-router-region` only adds those elements once, + * independently of the number of directives processed. + */ + public function test_wp_router_region_adds_loading_bar_aria_live_region_only_once() { + $html = ' +
Interactive region
+
Another interactive region
+ '; + $new_html = $this->interactivity->process_directives( $html ); + $footer = $this->render_wp_footer(); + + $this->assertSame( $html, $new_html ); + + $query = array( 'tag_name' => 'style' ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertSame( 'wp-interactivity-router_animations', $p->get_attribute( 'id' ) ); + $this->assertFalse( $p->next_tag( $query ) ); + + $query = array( + 'tag_name' => 'div', + 'class_name' => 'wp-interactivity-router_loading-bar', + ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + + $query = array( + 'tag_name' => 'div', + 'class_name' => 'screen-reader-text', + ); + + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + } +}