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' );