Commit ae6a3d8ab33 for woocommerce

commit ae6a3d8ab332c2b6cb05a4862e394b4b3e603926
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Wed Oct 21 17:37:46 2026 +0200

    Contain focus inside Product Filters overlay (#69933)

    * Contain focus inside Product Filters overlay

    * Add changelog

    * Code cleanup

    * Fix focus not working correctly

    * Fix tests

    * Make sure overlay is open before focusing the first item

    * refactor: use directive refs for product filter focus

    Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-455d-72af-923f-8f1282a624ba
    Co-authored-by: Amp <amp@ampcode.com>

    * fix: release product filter focus when overlay becomes inline

    Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-455d-72af-923f-8f1282a624ba
    Co-authored-by: Amp <amp@ampcode.com>

    * Linting

    ---------

    Co-authored-by: Tung Du <dinhtungdu@gmail.com>
    Co-authored-by: Amp <amp@ampcode.com>

diff --git a/plugins/woocommerce/changelog/fix-69079-product-filters-overlay-contain-focus b/plugins/woocommerce/changelog/fix-69079-product-filters-overlay-contain-focus
new file mode 100644
index 00000000000..7896a0a9f41
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69079-product-filters-overlay-contain-focus
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Contain focus inside Product Filters overlay
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
index 2be45222e57..e94c3a6d4a4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
@@ -25,6 +25,7 @@ import {
  * Internal dependencies
  */
 import type { CartItem, Currency } from '@woocommerce/types';
+import { getFocusableElements } from '../../utils/get-focusable-elements';
 import { translateJQueryEventToNative } from '../../base/stores/woocommerce/legacy-events';
 import {
 	getEntryFieldRaw,
@@ -152,23 +153,6 @@ const trimWords = ( html: string, maxWords = 15 ): string => {
 	return words.slice( 0, maxWords ).join( ' ' ) + '…';
 };

-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 getFocusableElements = ( container: HTMLElement | null ) =>
-	container
-		? Array.from(
-				container.querySelectorAll< HTMLElement >( focusableSelectors )
-			).filter( ( el ) => el.offsetParent !== null )
-		: [];
-
 const { state: woocommerceState, actions } = store< WooCommerce >(
 	'woocommerce',
 	{},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
index a8838296a4e..40142c6b57d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
@@ -10,6 +10,7 @@ import type { ProductFiltersStore } from '../frontend';
 const mockGetContext = jest.fn();
 const mockGetServerContext = jest.fn();
 const mockGetConfig = jest.fn();
+const mockGetElement = jest.fn();
 const mockReload = jest.fn();

 jest.mock( '../../../utils/navigation', () => ( {
@@ -19,6 +20,7 @@ jest.mock( '../../../utils/navigation', () => ( {
 let mockRegisteredStore: {
 	state: ProductFiltersStore[ 'state' ];
 	actions: ProductFiltersStore[ 'actions' ];
+	callbacks: ProductFiltersStore[ 'callbacks' ];
 } | null = null;

 jest.mock(
@@ -27,10 +29,13 @@ jest.mock(
 		getContext: mockGetContext,
 		getServerContext: mockGetServerContext,
 		getConfig: mockGetConfig,
+		getElement: mockGetElement,
+		withSyncEvent: ( callback: unknown ) => callback,
 		store: jest.fn( ( _name, definition ) => {
 			mockRegisteredStore = {
 				state: definition.state,
 				actions: definition.actions,
+				callbacks: definition.callbacks,
 			};
 			return mockRegisteredStore;
 		} ),
@@ -57,11 +62,13 @@ describe( 'product filters interactivity store', () => {
 		mockGetContext.mockReset();
 		mockGetServerContext.mockReset();
 		mockGetConfig.mockReset();
+		mockGetElement.mockReset();
+		mockGetElement.mockReturnValue( { ref: null } );
 		mockReload.mockReset();
 		mockRegisteredStore = null;

 		jest.isolateModules( () => {
-			require( '../frontend' );
+			jest.requireActual( '../frontend' );
 		} );
 	} );

@@ -206,6 +213,54 @@ describe( 'product filters interactivity store', () => {
 		expect( context.isOverlayOpened ).toBe( false );
 	} );

+	it( 'focuses the dialog after the overlay is shown', () => {
+		if ( ! mockRegisteredStore ) {
+			throw new Error( 'Product filters store was not registered.' );
+		}
+
+		const root = document.createElement( 'div' );
+		const openButton = document.createElement( 'button' );
+		const dialog = document.createElement( 'div' );
+		const closeButton = document.createElement( 'button' );
+		Object.defineProperty( closeButton, 'offsetParent', {
+			get: () => document.body,
+		} );
+		dialog.append( closeButton );
+		root.append( openButton, dialog );
+		document.body.append( root );
+
+		const context = { isOverlayOpened: false };
+		mockGetContext.mockReturnValue( context );
+		mockGetElement.mockReturnValue( { ref: openButton } );
+
+		let frame: ( ( time: number ) => void ) | undefined;
+		const requestAnimationFrameSpy = jest
+			.spyOn( window, 'requestAnimationFrame' )
+			.mockImplementation( ( callback ) => {
+				frame = callback;
+				return 1;
+			} );
+
+		try {
+			mockRegisteredStore.actions.openOverlay();
+			mockGetElement.mockReturnValue( { ref: dialog } );
+			mockRegisteredStore.callbacks.focusOverlay();
+
+			expect( context.isOverlayOpened ).toBe( true );
+			expect( root.ownerDocument.activeElement ).not.toBe( closeButton );
+
+			frame?.( 0 );
+
+			expect( root.ownerDocument.activeElement ).toBe( closeButton );
+
+			mockRegisteredStore.actions.closeOverlay();
+			expect( root.ownerDocument.activeElement ).toBe( openButton );
+		} finally {
+			root.remove();
+			requestAnimationFrameSpy.mockRestore();
+		}
+	} );
+
 	it( 'does not add child-owned index metadata to selectable items', () => {
 		if ( ! mockRegisteredStore ) {
 			throw new Error( 'Product filters store was not registered.' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
index d8429c182a6..2b066fe306d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
@@ -15,10 +15,18 @@ import type {
 	ProductFiltersContext,
 } from './types';
 import { getClosestColor } from './utils/get-closest-color';
+import { getFocusableElements } from '../../utils/get-focusable-elements';
 import { reload } from '../../utils/navigation';
 import { PRODUCT_FILTERS_STORE_NAME } from './constants';

-const { getContext, getElement, store, getServerContext, getConfig } = iAPI;
+const {
+	getContext,
+	getElement,
+	store,
+	getServerContext,
+	getConfig,
+	withSyncEvent,
+} = iAPI;

 const BLOCK_NAME = PRODUCT_FILTERS_STORE_NAME;

@@ -171,6 +179,7 @@ const productFiltersStore = {
 	actions: {
 		openOverlay: () => {
 			const context = getContext< ProductFiltersContext >();
+			context.openButtonRef = getElement().ref;
 			context.isOverlayOpened = true;
 			if ( document.getElementById( 'wpadminbar' ) ) {
 				const scrollTop = (
@@ -187,18 +196,56 @@ const productFiltersStore = {
 		closeOverlay: () => {
 			const context = getContext< ProductFiltersContext >();
 			context.isOverlayOpened = false;
+			context.openButtonRef?.focus();
+			context.openButtonRef = null;
 		},
 		closeOverlayOnBackdrop: ( event: MouseEvent ) => {
 			if ( event.target === event.currentTarget ) {
 				actions.closeOverlay();
 			}
 		},
-		closeOverlayOnEscape: ( event: KeyboardEvent ) => {
+		handleOverlayKeydown: withSyncEvent( ( event: KeyboardEvent ) => {
 			const context = getContext< ProductFiltersContext >();
-			if ( context.isOverlayOpened && event.key === 'Escape' ) {
+			if ( ! context.isOverlayOpened ) {
+				return;
+			}
+
+			if ( event.key === 'Escape' ) {
+				event.preventDefault();
 				actions.closeOverlay();
+				return;
 			}
-		},
+
+			if ( event.key !== 'Tab' ) {
+				return;
+			}
+
+			const { ref: dialog } = getElement();
+			if ( ! dialog ) {
+				return;
+			}
+
+			const focusableElements = getFocusableElements( dialog );
+			const first = focusableElements[ 0 ];
+			const last = focusableElements[ focusableElements.length - 1 ];
+			if ( ! first || ! last ) {
+				return;
+			}
+
+			const active = dialog.ownerDocument.activeElement;
+			const focusIsOutside = ! dialog.contains( active );
+
+			if ( event.shiftKey && ( active === first || focusIsOutside ) ) {
+				event.preventDefault();
+				last.focus();
+				return;
+			}
+
+			if ( ! event.shiftKey && ( active === last || focusIsOutside ) ) {
+				event.preventDefault();
+				first.focus();
+			}
+		} ),
 		removeActiveFiltersBy: (
 			callback: ( item: ActiveFilterItem ) => boolean
 		) => {
@@ -283,6 +330,37 @@ const productFiltersStore = {
 		},
 	},
 	callbacks: {
+		initOverlay: () => {
+			const { ref: openButton } = getElement();
+			if ( ! openButton ) {
+				return;
+			}
+			const context = getContext< ProductFiltersContext >();
+			const observer = new ResizeObserver( () => {
+				// Responsive CSS hides the opener when filters become inline.
+				if ( getComputedStyle( openButton ).display === 'none' ) {
+					context.isOverlayOpened = false;
+					context.openButtonRef = null;
+				}
+			} );
+			observer.observe( openButton );
+			return () => observer.disconnect();
+		},
+		focusOverlay: () => {
+			const context = getContext< ProductFiltersContext >();
+			if ( ! context.isOverlayOpened ) {
+				return;
+			}
+			const { ref: dialog } = getElement();
+
+			// Wait for the open class to make the drawer visible before focusing.
+			const frame = requestAnimationFrame( () => {
+				if ( context.isOverlayOpened ) {
+					getFocusableElements( dialog )[ 0 ]?.focus();
+				}
+			} );
+			return () => cancelAnimationFrame( frame );
+		},
 		initColors: () => {
 			const el = getElement();
 			if ( ! el.ref ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index 3e26e516eea..76f8863a4bb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -36,6 +36,7 @@ export type ActiveFilterItem = {

 export type ProductFiltersContext = {
 	isOverlayOpened: boolean;
+	openButtonRef?: HTMLElement | null;
 	params: Record< string, string >;
 	activeFilters: ActiveFilterItem[];
 	items?: FilterOptionItem[];
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/get-focusable-elements.ts b/plugins/woocommerce/client/blocks/assets/js/utils/get-focusable-elements.ts
new file mode 100644
index 00000000000..41e3d9bea43
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/get-focusable-elements.ts
@@ -0,0 +1,32 @@
+/**
+ * Elements that can be reached with Tab.
+ * Disabled fields, hidden inputs, and aria-hidden fields are excluded.
+ * A negative tabindex is excluded for elements that are not already a link, field, or button.
+ */
+const FOCUSABLE_SELECTOR = [
+	'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^="-"])',
+].join( ',' );
+
+/**
+ * Focusable descendants that are currently shown.
+ * An element hidden with `display: none` has no offsetParent, so it is skipped.
+ *
+ * @param container Element to search within. Nothing is returned when it is missing.
+ */
+export function getFocusableElements(
+	container?: HTMLElement | null
+): HTMLElement[] {
+	if ( ! container ) {
+		return [];
+	}
+
+	return Array.from(
+		container.querySelectorAll< HTMLElement >( FOCUSABLE_SELECTOR )
+	).filter( ( element ) => element.offsetParent !== null );
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/index.ts b/plugins/woocommerce/client/blocks/assets/js/utils/index.ts
index 4776c081ceb..f376b595a3e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/index.ts
@@ -14,6 +14,7 @@ export * from './is-widget-editor-page';
 export * from './trim-words';
 export * from './wordpress-stores';
 export * from './find-block';
+export * from './get-focusable-elements';
 export * from './get-unique-id';
 export * from './html-entities';
 export * from './get-inner-block-by-name';
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/test/get-focusable-elements.ts b/plugins/woocommerce/client/blocks/assets/js/utils/test/get-focusable-elements.ts
new file mode 100644
index 00000000000..ab38bececfc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/test/get-focusable-elements.ts
@@ -0,0 +1,59 @@
+/**
+ * Internal dependencies
+ */
+import { getFocusableElements } from '../get-focusable-elements';
+
+function markRendered( element: HTMLElement ) {
+	Object.defineProperty( element, 'offsetParent', {
+		get: () => document.body,
+	} );
+}
+
+describe( 'getFocusableElements', () => {
+	it( 'returns nothing when the container is missing', () => {
+		expect( getFocusableElements( null ) ).toEqual( [] );
+		expect( getFocusableElements() ).toEqual( [] );
+	} );
+
+	it( 'returns shown controls and skips hidden, disabled, and negative tabindex elements', () => {
+		const container = document.createElement( 'div' );
+		const link = document.createElement( 'a' );
+		link.href = 'https://example.com';
+		const button = document.createElement( 'button' );
+		const disabledButton = document.createElement( 'button' );
+		disabledButton.disabled = true;
+		const hiddenInput = document.createElement( 'input' );
+		hiddenInput.type = 'hidden';
+		const textInput = document.createElement( 'input' );
+		const ariaHiddenSelect = document.createElement( 'select' );
+		ariaHiddenSelect.setAttribute( 'aria-hidden', 'true' );
+		const skipped = document.createElement( 'span' );
+		skipped.tabIndex = -1;
+		const custom = document.createElement( 'span' );
+		custom.tabIndex = 0;
+
+		[
+			link,
+			button,
+			disabledButton,
+			hiddenInput,
+			textInput,
+			ariaHiddenSelect,
+			skipped,
+			custom,
+		].forEach( ( element ) => {
+			markRendered( element );
+			container.append( element );
+		} );
+
+		const hiddenButton = document.createElement( 'button' );
+		container.append( hiddenButton );
+
+		expect( getFocusableElements( container ) ).toEqual( [
+			link,
+			button,
+			textInput,
+			custom,
+		] );
+	} );
+} );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
index 92422ce73a5..c654546ea19 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
@@ -244,7 +244,6 @@ CSS;

 		if ( $has_overlay ) {
 			$wrapper_attributes['data-wp-watch---scrolling']        = 'callbacks.scrollLimit';
-			$wrapper_attributes['data-wp-on--keyup']                = 'actions.closeOverlayOnEscape';
 			$wrapper_attributes['data-wp-class--is-overlay-opened'] = 'context.isOverlayOpened';
 		}

@@ -255,6 +254,7 @@ CSS;
 				<button
 					type="button"
 					class="wc-block-product-filters__open-overlay"
+					data-wp-init="callbacks.initOverlay"
 					data-wp-on--click="actions.openOverlay"
 				>
 					<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -268,6 +268,9 @@ CSS;
 							class="wc-block-product-filters__overlay-dialog"
 							role="dialog"
 							aria-label="<?php echo esc_attr__( 'Product Filters', 'woocommerce' ); ?>"
+							data-wp-bind--aria-modal="context.isOverlayOpened"
+							data-wp-on-document--keydown="actions.handleOverlayKeydown"
+							data-wp-watch="callbacks.focusOverlay"
 						>
 							<header class="wc-block-product-filters__overlay-header">
 								<button
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
index 861844a5539..9c60185cd7b 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
@@ -9,11 +9,11 @@ import {
 } from '@woocommerce/e2e-utils';

 const test = base.extend< { templateCompiler: TemplateCompiler } >( {
-	templateCompiler: async ( { requestUtils }, use ) => {
+	templateCompiler: async ( { requestUtils }, provideTemplate ) => {
 		const compiler = await requestUtils.createTemplateFromFile(
 			'archive-product_attribute-filter'
 		);
-		await use( compiler );
+		await provideTemplate( compiler );
 	},
 } );

@@ -66,39 +66,71 @@ test.describe( 'woocommerce/product-filters - Frontend', () => {
 				name: 'Close',
 			} );

-			await expect( closeOverlayButton ).toBeVisible();
+			await expect( closeOverlayButton ).toBeFocused();

 			await closeOverlayButton.click();

+			await expect( openOverlayButton ).toBeFocused();
 			await expect( overlay ).toHaveCount( 0 );

 			// Close overlay by hitting Esc.
 			await openOverlayButton.click();

 			await expect( overlay ).toBeInViewport();
+			await expect( closeOverlayButton ).toBeFocused();

 			await page.keyboard.press( 'Escape' );

+			await expect( openOverlayButton ).toBeFocused();
 			await expect( overlay ).toHaveCount( 0 );

 			// Close overlay by clicking Apply button.
 			await openOverlayButton.click();

 			await expect( overlay ).toBeInViewport();
+			await expect( closeOverlayButton ).toBeFocused();

 			const applyButton = page.getByRole( 'button', {
 				name: 'Apply',
 			} );

-			await expect( applyButton ).toBeVisible();
+			await applyButton.focus();
+			await page.keyboard.press( 'Tab' );
+			await expect( closeOverlayButton ).toBeFocused();

 			await applyButton.click();

+			await expect( openOverlayButton ).toBeFocused();
 			await expect( overlay ).toHaveCount( 0 );
+
+			await openOverlayButton.click();
+			await expect( closeOverlayButton ).toBeFocused();
+			const lastFilter = overlay.getByRole( 'checkbox' ).last();
+			await lastFilter.focus();
+
+			await page.setViewportSize( { width: 1280, height: 800 } );
+			await expect( openOverlayButton ).toBeHidden();
+			await expect( overlay ).not.toHaveAttribute( 'aria-modal', 'true' );
+			await expect( lastFilter ).toBeFocused();
+			await page.keyboard.press( 'Tab' );
+			await expect
+				.poll( () =>
+					overlay.evaluate( ( element ) =>
+						element.contains( element.ownerDocument.activeElement )
+					)
+				)
+				.toBe( false );
+
+			await page.setViewportSize( { width: 400, height: 600 } );
+			await expect( overlay ).toHaveCount( 0 );
+			await openOverlayButton.click();
+			await expect( closeOverlayButton ).toBeFocused();
+			await page.keyboard.press( 'Escape' );
+			await expect( openOverlayButton ).toBeFocused();
 		} );

 		// Skipping these tests until we can move this block to @wordpress/interactivity.
-		// eslint-disable-next-line playwright/no-skipped-test
+
 		test.skip( 'filter is working inside overlay', async ( { page } ) => {
 			await page.setViewportSize( { width: 400, height: 600 } );
 			await page.goto( '/shop' );