Commit 3dcf508512e for woocommerce
commit 3dcf508512e30d81fc15e997e1acd11bb6669016
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date: Wed Nov 4 17:34:22 2026 +0100
Announce the Product Results Count after client-side navigation (#69897)
The router speaks "Page Loaded." as the count changes, so screen readers
missed the new count. The block now speaks it 500 ms after each navigation,
and on load when the URL has a query string. It drops role="status" from its
inner paragraph, and the legacy sorted-by script skips it, which also fixes
the visible count sticking after a query-string load.
Closes #69081.
diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index 71680282f1b..4c32b0e8ad3 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -1411,7 +1411,7 @@ Display the number of products on the archive page or search result page.
- **Name:** woocommerce/product-results-count
- **Category:** woocommerce
-- **Supports:** color (background, gradients, text), interactivity (clientNavigation), spacing (margin, padding), typography (fontSize, textAlign)
+- **Supports:** color (background, gradients, text), interactivity (clientNavigation, interactive), spacing (margin, padding), typography (fontSize, textAlign)
## Product Reviews - woocommerce/product-reviews
diff --git a/plugins/woocommerce/changelog/69081-results-count-announcement b/plugins/woocommerce/changelog/69081-results-count-announcement
new file mode 100644
index 00000000000..85f635ed668
--- /dev/null
+++ b/plugins/woocommerce/changelog/69081-results-count-announcement
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Announce the updated product count to screen readers after filtering, sorting, or changing pages.
diff --git a/plugins/woocommerce/changelog/69081-results-count-stale-text b/plugins/woocommerce/changelog/69081-results-count-stale-text
new file mode 100644
index 00000000000..dcf3eb0819c
--- /dev/null
+++ b/plugins/woocommerce/changelog/69081-results-count-stale-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the product count up to date when browsing a shop page that was opened with sorting or filters in its URL.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/__tests__/frontend.test.ts
new file mode 100644
index 00000000000..b9006eefea6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/__tests__/frontend.test.ts
@@ -0,0 +1,378 @@
+const mockGetServerContext = jest.fn();
+const mockGetElement = jest.fn();
+const mockSpeak = jest.fn();
+let mockCallbacks: {
+ announceResultsCount: () => ( () => void ) | void;
+} | null = null;
+let announcementDelayMs: number;
+// Cleanups returned by each block's last watch run, as the watch effect keeps them.
+let cleanups = new Map< HTMLElement, () => void >();
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getElement: mockGetElement,
+ getServerContext: mockGetServerContext,
+ store: jest.fn( ( _name, definition ) => {
+ mockCallbacks = definition.callbacks;
+ return definition;
+ } ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock( '@wordpress/a11y', () => ( { speak: mockSpeak } ), {
+ virtual: true,
+} );
+
+// Blocks the server rendered with the page; tests add them before the module loads.
+let firstBlock: HTMLElement;
+let secondBlock: HTMLElement;
+
+const pageBlock = () => {
+ const block = document.createElement( 'div' );
+ block.className = 'wc-block-product-results-count';
+ document.body.append( block );
+ return block;
+};
+
+const announce = ( text: string, block: HTMLElement = firstBlock ) => {
+ if ( ! mockCallbacks ) {
+ throw new Error( 'Product Results Count store was not registered.' );
+ }
+ // Like the watch effect, run the previous cleanup before re-running the callback.
+ cleanups.get( block )?.();
+ mockGetElement.mockReturnValue( { ref: block } );
+ mockGetServerContext.mockReturnValue( { announcement: text } );
+ const cleanup = mockCallbacks.announceResultsCount();
+ if ( cleanup ) {
+ cleanups.set( block, cleanup );
+ }
+};
+
+// The router removed the block, for example on a page with no results.
+const removeBlock = ( block: HTMLElement = firstBlock ) => {
+ cleanups.get( block )?.();
+ cleanups.delete( block );
+};
+
+const settle = async () => {
+ jest.runAllTimers();
+ // Let the dynamic import of @wordpress/a11y resolve.
+ await new Promise( jest.requireActual( 'timers' ).setImmediate );
+};
+
+describe( 'Product Results Count announcement', () => {
+ beforeEach( async () => {
+ jest.useFakeTimers();
+ jest.resetModules();
+ mockGetServerContext.mockReset();
+ mockGetElement.mockReset();
+ mockSpeak.mockReset();
+ mockCallbacks = null;
+ cleanups = new Map();
+ document.body.innerHTML = '';
+ firstBlock = pageBlock();
+ secondBlock = pageBlock();
+ await jest.isolateModulesAsync( async () => {
+ ( { ANNOUNCEMENT_DELAY_MS: announcementDelayMs } =
+ await import( '../frontend' ) );
+ } );
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it( 'does not announce on the initial render', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( 'does not announce on the initial render of several blocks', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 1–16 of 28 results', secondBlock );
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( 'announces the new count after a navigation', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 17–28 of 28 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'announces once, with the last count, after quick successive changes', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing all 9 results' );
+ announce( 'Showing all 3 results' );
+ announce( 'Showing all 2 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing all 2 results',
+ 'polite'
+ );
+ } );
+
+ it( 'waits before announcing so the router speaks first', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing all 2 results' );
+ jest.advanceTimersByTime( announcementDelayMs - 1 );
+ await new Promise( jest.requireActual( 'timers' ).setImmediate );
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'announces an unchanged count after a navigation', async () => {
+ announce( 'Showing all 5 results' );
+ announce( 'Showing all 5 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing all 5 results',
+ 'polite'
+ );
+ } );
+
+ it( 'announces once when two blocks show the same new count', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 1–16 of 28 results', secondBlock );
+
+ announce( 'Showing 17–28 of 28 results' );
+ announce( 'Showing 17–28 of 28 results', secondBlock );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'announces the changed count when a later block is unchanged', async () => {
+ const otherQueryBlock = secondBlock;
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+
+ announce( 'Showing 17–28 of 28 results' );
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'announces the changed count when an earlier block is unchanged', async () => {
+ const otherQueryBlock = secondBlock;
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+ announce( 'Showing 1–16 of 28 results' );
+
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+ announce( 'Showing 17–28 of 28 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'does not cancel a pending announcement when another block mounts', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 17–28 of 28 results' );
+ announce(
+ 'Showing 17–28 of 28 results',
+ document.createElement( 'div' )
+ );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'announces an unchanged count on the navigation after a changed one', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing all 5 results' );
+ await settle();
+ announce( 'Showing all 5 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 2 );
+ expect( mockSpeak ).toHaveBeenLastCalledWith(
+ 'Showing all 5 results',
+ 'polite'
+ );
+ } );
+
+ describe( 'on a page loaded with a query string', () => {
+ beforeEach( () => {
+ window.history.pushState( {}, '', '/shop/?orderby=price' );
+ } );
+
+ afterEach( () => {
+ window.history.pushState( {}, '', '/' );
+ } );
+
+ it( 'announces the count once on page load', async () => {
+ announce(
+ 'Showing 1–16 of 28 results Sorted by price: low to high'
+ );
+ announce(
+ 'Showing 1–16 of 28 results Sorted by price: low to high',
+ secondBlock
+ );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 1–16 of 28 results Sorted by price: low to high',
+ 'polite'
+ );
+ } );
+
+ it( 'does not let a mounting block replace a changed count', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ await settle();
+ mockSpeak.mockReset();
+
+ announce( 'Showing 17–28 of 28 results' );
+ announce(
+ 'Showing 1–4 of 12 results',
+ document.createElement( 'div' )
+ );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+ } );
+
+ it( 'does not announce an empty count', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( '' );
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( 'drops a pending count when the same block then shows no results', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing the single result' );
+ jest.advanceTimersByTime( 300 );
+ announce( '' );
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( 'announces a block that mounts after a navigation, such as clearing filters with no results', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ removeBlock();
+ await settle();
+
+ announce(
+ 'Showing 1–16 of 28 results',
+ document.createElement( 'div' )
+ );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 1–16 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( 'does not speak a count replaced while @wordpress/a11y is loading', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 17–28 of 28 results' );
+ // The timer fires and starts the import; the next navigation lands before it resolves.
+ jest.advanceTimersByTime( announcementDelayMs );
+ announce( 'Showing all 2 results' );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing all 2 results',
+ 'polite'
+ );
+ } );
+
+ it( 'does not speak a count after the block leaves the page while @wordpress/a11y is loading', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing the single result' );
+ jest.advanceTimersByTime( announcementDelayMs );
+ removeBlock();
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( 'drops a pending count when the block is removed from the page', async () => {
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing the single result' );
+ jest.advanceTimersByTime( 300 );
+ removeBlock();
+ await settle();
+
+ expect( mockSpeak ).not.toHaveBeenCalled();
+ } );
+
+ it( "keeps another block's pending count when a block is removed", async () => {
+ const otherQueryBlock = secondBlock;
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+
+ announce( 'Showing 17–28 of 28 results' );
+ removeBlock( otherQueryBlock );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+
+ it( "keeps another block's pending count when a block shows no results", async () => {
+ const otherQueryBlock = secondBlock;
+ announce( 'Showing 1–16 of 28 results' );
+ announce( 'Showing 1–4 of 12 results', otherQueryBlock );
+
+ announce( 'Showing 17–28 of 28 results' );
+ announce( '', otherQueryBlock );
+ await settle();
+
+ expect( mockSpeak ).toHaveBeenCalledTimes( 1 );
+ expect( mockSpeak ).toHaveBeenCalledWith(
+ 'Showing 17–28 of 28 results',
+ 'polite'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/block.json
index b1c7b5b12ed..0ed992fb1d3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/block.json
@@ -6,6 +6,7 @@
"keywords": [ "WooCommerce" ],
"supports": {
"interactivity": {
+ "interactive": true,
"clientNavigation": true
},
"color": {
@@ -27,6 +28,7 @@
}
},
"usesContext": [ "queryId" ],
+ "viewScriptModule": "woocommerce/product-results-count",
"textdomain": "woocommerce",
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/frontend.ts
new file mode 100644
index 00000000000..77c4252ed44
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-results-count/frontend.ts
@@ -0,0 +1,112 @@
+/**
+ * External dependencies
+ */
+import { getElement, getServerContext, store } from '@wordpress/interactivity';
+
+const BLOCK_NAME = 'woocommerce/product-results-count';
+
+/**
+ * How long to wait before speaking, so the count follows the router's
+ * "Page Loaded." message and quick successive changes speak only once.
+ */
+export const ANNOUNCEMENT_DELAY_MS = 500;
+
+type ProductResultsCountContext = {
+ announcement: string;
+};
+
+// Each block's last count. A block without an entry has not run its first watch yet.
+const lastAnnouncements = new WeakMap< HTMLElement, string >();
+
+// Blocks rendered with the page. A block that mounts later came from a navigation, such as clearing filters on a page with no results.
+const pageLoadBlocks = new WeakSet< Element >(
+ Array.from( document.querySelectorAll( '.wc-block-product-results-count' ) )
+);
+
+// The queued count. It stays set until @wordpress/a11y has loaded, so a later navigation can still cancel it.
+let pendingAnnouncement:
+ | {
+ timer?: ReturnType< typeof setTimeout >;
+ isChanged: boolean;
+ block: HTMLElement | null;
+ }
+ | undefined;
+
+/**
+ * Whether a full page load should announce the count: when the URL has a query string, such as a
+ * sorted or filtered link or a page reload after either. Unlike the legacy script, this includes
+ * pages with Product Filters, which it only skipped to avoid overwriting the count (#63969).
+ */
+const shouldAnnounceOnPageLoad = (): boolean => window.location.search !== '';
+
+const productResultsCountStore = {
+ callbacks: {
+ /**
+ * Speak the new count after client-side navigation replaces it.
+ *
+ * Every block's watch re-runs on each navigation, so a block whose count did not
+ * change must not replace an announcement from a block whose count did.
+ *
+ * @return Cleanup the watch runs before the next run and when the block leaves the page.
+ */
+ announceResultsCount: () => {
+ // Read on every run, including the first, so the watch re-runs on every navigation.
+ const { announcement } =
+ getServerContext< ProductResultsCountContext >();
+ const { ref } = getElement();
+ // A page with no results drops the block, so its queued count must not be spoken.
+ const cancelOwnAnnouncement = () => {
+ if ( ref && pendingAnnouncement?.block === ref ) {
+ clearTimeout( pendingAnnouncement.timer );
+ pendingAnnouncement = undefined;
+ }
+ };
+ const previousAnnouncement = ref
+ ? lastAnnouncements.get( ref )
+ : undefined;
+ if ( ref ) {
+ lastAnnouncements.set( ref, announcement );
+ }
+
+ const isFirstRun = previousAnnouncement === undefined;
+ const isPageLoad =
+ isFirstRun && ( ! ref || pageLoadBlocks.has( ref ) );
+ const isChanged =
+ ! isFirstRun && announcement !== previousAnnouncement;
+ if (
+ ( isPageLoad && ! shouldAnnounceOnPageLoad() ) ||
+ ! announcement ||
+ ( ! isChanged && pendingAnnouncement?.isChanged )
+ ) {
+ return cancelOwnAnnouncement;
+ }
+
+ clearTimeout( pendingAnnouncement?.timer );
+ const queued: NonNullable< typeof pendingAnnouncement > = {
+ isChanged,
+ block: ref,
+ };
+ queued.timer = setTimeout( () => {
+ import( '@wordpress/a11y' )
+ .then( ( { speak } ) => {
+ if ( pendingAnnouncement === queued ) {
+ speak( announcement, 'polite' );
+ }
+ } )
+ .catch( () => {
+ // A failed chunk load leaves the router's message as the only feedback.
+ } )
+ .finally( () => {
+ if ( pendingAnnouncement === queued ) {
+ pendingAnnouncement = undefined;
+ }
+ } );
+ }, ANNOUNCEMENT_DELAY_MS );
+ pendingAnnouncement = queued;
+
+ return cancelOwnAnnouncement;
+ },
+ },
+};
+
+store( BLOCK_NAME, productResultsCountStore, { lock: true } );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js
index 4bd87c1a0f3..ffa355ed9a0 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/woocommerce.js
@@ -186,3 +186,51 @@ describe( 'woocommerce.js form encoding prefilter', () => {
expect( raw.data ).toBe( "city=O'Fallon" );
} );
} );
+
+describe( 'refresh_sorted_by_live_region', () => {
+ const loadScript = () => {
+ global.jQuery = jest.fn();
+ jest.isolateModules( () => {
+ require( '../woocommerce' );
+ } );
+ document.dispatchEvent( new Event( 'DOMContentLoaded' ) );
+ };
+
+ beforeEach( () => {
+ jest.useFakeTimers();
+ window.history.pushState( {}, '', '/shop/?orderby=price' );
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ document.body.innerHTML = '';
+ window.history.pushState( {}, '', '/' );
+ } );
+
+ it( 'turns the classic result count into an alert after a sorted page load', () => {
+ document.body.innerHTML =
+ '<p class="woocommerce-result-count">Showing 1–16 of 18 results</p>';
+
+ loadScript();
+ jest.runAllTimers();
+
+ const resultCount = document.querySelector( '.woocommerce-result-count' );
+ expect( resultCount.getAttribute( 'role' ) ).toBe( 'alert' );
+ expect( resultCount.getAttribute( 'aria-hidden' ) ).toBe( 'false' );
+ } );
+
+ it( 'leaves the Product Results Count block untouched', () => {
+ document.body.innerHTML =
+ '<div class="wc-block-product-results-count"><p class="woocommerce-result-count">Showing 1–16 of 18 results</p></div>';
+ const resultCount = document.querySelector( '.woocommerce-result-count' );
+ const textNode = resultCount.firstChild;
+
+ loadScript();
+ jest.runAllTimers();
+
+ expect( resultCount.hasAttribute( 'role' ) ).toBe( false );
+ expect( resultCount.hasAttribute( 'aria-hidden' ) ).toBe( false );
+ // The block's text node must survive, or client-side navigation updates a detached node.
+ expect( resultCount.firstChild ).toBe( textNode );
+ } );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js b/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
index 50f55eff677..260b151005c 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/woocommerce.js
@@ -295,7 +295,9 @@ function focus_populate_live_region() {
* Refresh the sorted by live region.
*
* Skips when the Interactivity API product filters are present on the page,
- * as those manage the result count updates themselves.
+ * as those manage the result count updates themselves. Also skips the Product
+ * Results Count block, which announces the count itself; rewriting its HTML
+ * would detach the text that client-side navigation updates.
*/
function refresh_sorted_by_live_region() {
var sorted_by_live_region = document.querySelector(
@@ -308,7 +310,8 @@ function refresh_sorted_by_live_region() {
if (
! sorted_by_live_region ||
! window.location.search ||
- hasInteractivityFilters
+ hasInteractivityFilters ||
+ sorted_by_live_region.closest( '.wc-block-product-results-count' )
) {
return;
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductResultsCount.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductResultsCount.php
index bc6877f0a61..f8e2a9fcdcf 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductResultsCount.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductResultsCount.php
@@ -24,6 +24,20 @@ class ProductResultsCount extends AbstractBlock {
return null;
}
+ /**
+ * Get the frontend style handles for this block type.
+ *
+ * The interactivity build writes this block's stylesheet to the woocommerce/ folder, so the default
+ * path would 404. The handle names stay the same for code that depends on or dequeues them.
+ *
+ * @return string[]
+ */
+ protected function get_block_type_style() {
+ $this->asset_api->register_style( 'wc-blocks-style-' . $this->block_name, $this->asset_api->get_block_asset_build_path( 'woocommerce/' . $this->block_name . '-style', 'css' ), array(), 'all', true );
+
+ return array( 'wc-blocks-style', 'wc-blocks-style-' . $this->block_name );
+ }
+
/**
* Render the block.
*
@@ -37,7 +51,15 @@ class ProductResultsCount extends AbstractBlock {
// Buffer the result count and use it as the block's frontend content.
ob_start();
woocommerce_result_count();
- $product_results_count = ob_get_clean();
+ $product_results_count = (string) ob_get_clean();
+
+ $processor = new \WP_HTML_Tag_Processor( $product_results_count );
+ if ( $processor->next_tag( array( 'class_name' => 'woocommerce-result-count' ) ) ) {
+ // The block speaks the count after client-side navigation; a second live region would compete with it.
+ $processor->remove_attribute( 'role' );
+ $processor->remove_attribute( 'aria-relevant' );
+ }
+ $product_results_count = $processor->get_updated_html();
$classes = 'woocommerce wc-block-product-results-count';
if ( ! empty( $attributes['fontSize'] ) ) {
@@ -50,9 +72,36 @@ class ProductResultsCount extends AbstractBlock {
'class' => $classes,
'data-wp-interactive' => $this->get_full_block_name(),
'data-wp-router-region' => 'wc-product-results-count-' . ( $block->context['queryId'] ?? 0 ),
+ 'data-wp-context' => (string) wp_json_encode( array( 'announcement' => $this->get_announcement_text( $product_results_count ) ) ),
+ 'data-wp-watch' => 'callbacks.announceResultsCount',
)
);
return sprintf( '<div %1$s>%2$s</div>', $wrapper_attributes, $product_results_count );
}
+
+ /**
+ * Get the result count as plain text, with each text fragment (such as the hidden sort label) separated by a space.
+ *
+ * @param string $markup Result count markup.
+ *
+ * @return string Text for screen readers.
+ */
+ private function get_announcement_text( string $markup ): string {
+ $parts = array();
+ $processor = new \WP_HTML_Tag_Processor( $markup );
+
+ while ( $processor->next_token() ) {
+ if ( '#text' !== $processor->get_token_name() ) {
+ continue;
+ }
+
+ $part = trim( $processor->get_modifiable_text() );
+ if ( '' !== $part ) {
+ $parts[] = $part;
+ }
+ }
+
+ return implode( ' ', $parts );
+ }
}
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-results-count/product-results-count.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-results-count/product-results-count.block_theme.spec.ts
index dba88ddc7f2..1818fbcf55b 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-results-count/product-results-count.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-results-count/product-results-count.block_theme.spec.ts
@@ -44,4 +44,21 @@ test.describe( `${ blockData.slug } Block`, () => {
await requestUtils.revertTemplate( 'wp_template', template.id );
}
} );
+
+ test( 'announces the new count to screen readers after sorting', async ( {
+ page,
+ } ) => {
+ await page.goto( '/shop/' );
+
+ await page
+ .getByRole( 'combobox', { name: 'Shop order' } )
+ .selectOption( 'price' );
+
+ await expect( page ).toHaveURL(
+ ( url ) => url.searchParams.get( 'orderby' ) === 'price'
+ );
+ await expect( page.locator( '#a11y-speak-polite' ) ).toHaveText(
+ /^Showing .+ results Sorted by price: low to high/
+ );
+ } );
} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductResultsCountTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductResultsCountTest.php
index 51e24fb1fdc..ee4a5e90fdc 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductResultsCountTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductResultsCountTest.php
@@ -93,4 +93,58 @@ class ProductResultsCountTest extends WP_UnitTestCase {
$this->assertTrue( $p->next_tag( array( 'class_name' => 'wc-block-product-results-count' ) ), 'The standalone result count wrapper should exist.' );
$this->assertSame( 'wc-product-results-count-0', $p->get_attribute( 'data-wp-router-region' ), 'The standalone router region should use the default query ID.' );
}
+
+ /**
+ * @testdox Exposes the count as plain text for the announcement after client-side navigation.
+ */
+ public function test_exposes_announcement_text_in_context(): void {
+ $markup = do_blocks( '<!-- wp:woocommerce/product-results-count /-->' );
+ $p = new WP_HTML_Tag_Processor( $markup );
+
+ $this->assertTrue( $p->next_tag( array( 'class_name' => 'wc-block-product-results-count' ) ), 'The result count wrapper should exist.' );
+ $this->assertSame( 'callbacks.announceResultsCount', $p->get_attribute( 'data-wp-watch' ), 'The wrapper should register the announcement callback.' );
+
+ $context = json_decode( (string) $p->get_attribute( 'data-wp-context' ), true );
+ $this->assertIsArray( $context, 'The wrapper should carry a JSON context.' );
+ $this->assertSame( 'Showing 5–8 of 12 results', $context['announcement'] ?? null, 'The announcement should be the decoded count text.' );
+ }
+
+ /**
+ * @testdox Joins the hidden sort label to the count with a space.
+ */
+ public function test_announcement_includes_sort_label(): void {
+ $_GET['orderby'] = 'price'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Test fixture for the sort label.
+ try {
+ $markup = do_blocks( '<!-- wp:woocommerce/product-results-count /-->' );
+ } finally {
+ unset( $_GET['orderby'] );
+ }
+ $p = new WP_HTML_Tag_Processor( $markup );
+ $p->next_tag( array( 'class_name' => 'wc-block-product-results-count' ) );
+
+ $context = json_decode( (string) $p->get_attribute( 'data-wp-context' ), true );
+ $this->assertSame( 'Showing 5–8 of 12 results Sorted by price: low to high', $context['announcement'] ?? null, 'The sort label should follow the count after a space.' );
+ }
+
+ /**
+ * @testdox Removes the template's live-region attributes inside the block.
+ */
+ public function test_inner_paragraph_is_not_a_live_region(): void {
+ $markup = do_blocks( '<!-- wp:woocommerce/product-results-count /-->' );
+ $p = new WP_HTML_Tag_Processor( $markup );
+
+ $this->assertTrue( $p->next_tag( array( 'class_name' => 'woocommerce-result-count' ) ), 'The inner paragraph should exist.' );
+ $this->assertNull( $p->get_attribute( 'role' ), 'The block announces the count itself, so the paragraph must not be a second live region.' );
+ $this->assertNull( $p->get_attribute( 'aria-relevant' ), 'The paragraph should not keep live-region settings.' );
+ }
+
+ /**
+ * @testdox Registers the block stylesheet under its existing handle at the interactivity build path.
+ */
+ public function test_style_handle_points_to_interactivity_build(): void {
+ $style = wp_styles()->query( 'wc-blocks-style-product-results-count', 'registered' );
+
+ $this->assertInstanceOf( \_WP_Dependency::class, $style, 'The block style handle should stay registered.' );
+ $this->assertStringContainsString( 'assets/client/blocks/woocommerce/product-results-count-style.css', (string) $style->src, 'An interactive block is built to the woocommerce/ folder, not to product-results-count.css.' );
+ }
}