Commit e9474ddecc9 for woocommerce
commit e9474ddecc90b475d6966dd48b31d3b621d22d4b
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu Mar 26 14:35:27 2026 +0300
Add accessibility features to fulfillments (#60495)
* Add ambiguous provider match notice to tracking number lookup form
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Add tests
* Add accessibility to fulfillment drawer - part 1
* Fix input focus issues, remove duplicate speaks, replace HTML button with React component, add action button descriptions, fix tests and styling
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Fix linter
* Fix another linter error
* Fix error created after merge conflict resolution
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Fix fulfillment drawer accessibility issues (WOOPLUG-5066)
- Use useInstanceId for unique ARIA IDs on action buttons
- Remove redundant aria-label that overrides visible button text (WCAG 2.5.3)
- Move screen-reader-text spans outside Button to prevent double naming
- Remove aria-live from item selector count div (speak() handles announcements)
- Replace setTimeout with double requestAnimationFrame for focus management
- Add focus trapping and focus restoration tests for the drawer
- Add speak() announcement tests for item-selector and shipment-tracking-number-form
* Fix lint errors in fulfillment test files
- Fix prettier formatting on toMatch() calls
- Use ownerDocument.activeElement instead of document.activeElement
* Address code review feedback for fulfillment accessibility
- Use useRef instead of createRef in CustomerNotificationBox
- Wrap close button aria-label in __() for i18n
- Replace :focus with :focus-visible for WCAG 2.4.7 compliance
- Remove redundant aria-live on role="alert" error container
- Remove aria-live="polite" from fulfill button to avoid double announcements
- Add requestAnimationFrame cleanup in drawer and editor useEffects
- Replace hardcoded IDs with useInstanceId in ShipmentTrackingNumberForm
- Guard focus restoration with isConnected check
* Remove aria-label override on fulfillment header and fix decorative image alt
- Remove aria-label from fulfillment editor expand/collapse header so the
accessible name comes from the heading text (e.g., "Fulfillment #1"),
giving screen readers fulfillment-specific context. aria-expanded already
conveys the expand/collapse state.
- Use empty alt on decorative provider icon that is already aria-hidden.
* Fix focus trap escape on Shift+Tab and add fallback aria-label to drawer
- Include drawer panel element in Shift+Tab boundary check so focus
wraps to the last element instead of escaping the trap
- Add fallback aria-label for the dialog so it has an accessible name
while the header component is loading (aria-labelledby takes
precedence once the referenced element exists)
* Use WordPress admin button focus style (box-shadow) instead of outline
Match the standard WordPress admin focus convention: box-shadow with
--wp-admin-border-width-focus and --wp-admin-theme-color, applied via
:focus:not(:disabled). Also fix the 4th instance in card.scss that was
still suppressing focus indicators.
* Revert card.scss focus style change (out of scope)
* Address code review feedback
- Switch :focus:not(:disabled) to :focus-visible for keyboard-only focus rings
- Remove dead aria-label from drawer panel (aria-labelledby takes precedence)
- Replace ariaDescription DOM mutation with aria-describedby for universal support
- Filter disabled elements from focus trap selector to prevent trapping on unfocusable elements
- Add console.error in tracking number lookup catch block for debuggability
* Update drawer accessibility test for aria-label removal
---------
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/60495-add-WOOPLUG-5066-fulfillments-accessibility-requirements b/plugins/woocommerce/changelog/60495-add-WOOPLUG-5066-fulfillments-accessibility-requirements
new file mode 100644
index 00000000000..a54a4d86009
--- /dev/null
+++ b/plugins/woocommerce/changelog/60495-add-WOOPLUG-5066-fulfillments-accessibility-requirements
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Improve fulfillment drawer accessibility: unique ARIA IDs, fix redundant aria-labels, eliminate double screen reader announcements, and replace setTimeout-based focus management with requestAnimationFrame
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/cancel-link.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/cancel-link.tsx
index 487b68e0346..010c25384e2 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/cancel-link.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/cancel-link.tsx
@@ -3,20 +3,35 @@
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
export default function CancelLink( { onClick }: { onClick: () => void } ) {
+ const descriptionId = useInstanceId(
+ CancelLink,
+ 'cancel-link-description'
+ ) as string;
+
return (
- <Button
- variant="link"
- onClick={ onClick }
- style={ { flex: 1 } }
- __next40pxDefaultSize
- >
- { __( 'Cancel', 'woocommerce' ) }
- </Button>
+ <>
+ <Button
+ variant="link"
+ onClick={ onClick }
+ style={ { flex: 1 } }
+ __next40pxDefaultSize
+ aria-describedby={ descriptionId }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __(
+ 'Cancels the current operation without saving changes',
+ 'woocommerce'
+ ) }
+ </span>
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/edit-fulfillment-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/edit-fulfillment-button.tsx
index b1423387c48..9a535532c2c 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/edit-fulfillment-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/edit-fulfillment-button.tsx
@@ -3,15 +3,34 @@
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
+import { useInstanceId } from '@wordpress/compose';
export default function EditFulfillmentButton( {
onClick,
}: {
onClick: () => void;
} ) {
+ const descriptionId = useInstanceId(
+ EditFulfillmentButton,
+ 'edit-fulfillment-description'
+ ) as string;
+
return (
- <Button variant="secondary" onClick={ onClick } __next40pxDefaultSize>
- { __( 'Edit fulfillment', 'woocommerce' ) }
- </Button>
+ <>
+ <Button
+ variant="secondary"
+ onClick={ onClick }
+ __next40pxDefaultSize
+ aria-describedby={ descriptionId }
+ >
+ { __( 'Edit fulfillment', 'woocommerce' ) }
+ </Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __(
+ 'Opens the fulfillment editor to modify fulfillment details',
+ 'woocommerce'
+ ) }
+ </span>
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/fulfill-items-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/fulfill-items-button.tsx
index 0492a390592..62d2890a7ad 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/fulfill-items-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/fulfill-items-button.tsx
@@ -5,6 +5,7 @@ import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useDispatch, select } from '@wordpress/data';
import { useState } from 'react';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@@ -26,6 +27,10 @@ export default function FulfillItemsButton( {
const { order, fulfillment, notifyCustomer } = useFulfillmentContext();
const [ isExecuting, setIsExecuting ] = useState( false );
const { saveFulfillment } = useDispatch( FulfillmentStore );
+ const descriptionId = useInstanceId(
+ FulfillItemsButton,
+ 'fulfill-items-description'
+ ) as string;
const handleFulfillItems = async () => {
setError( null );
@@ -56,14 +61,25 @@ export default function FulfillItemsButton( {
};
return (
- <Button
- variant="primary"
- onClick={ handleFulfillItems }
- __next40pxDefaultSize
- isBusy={ isExecuting }
- disabled={ isExecuting }
- >
- { __( 'Fulfill items', 'woocommerce' ) }
- </Button>
+ <>
+ <Button
+ variant="primary"
+ onClick={ handleFulfillItems }
+ __next40pxDefaultSize
+ isBusy={ isExecuting }
+ disabled={ isExecuting }
+ aria-describedby={ descriptionId }
+ >
+ { isExecuting
+ ? __( 'Fulfilling…', 'woocommerce' )
+ : __( 'Fulfill items', 'woocommerce' ) }
+ </Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __(
+ 'Marks the selected items as fulfilled and updates their status',
+ 'woocommerce'
+ ) }
+ </span>
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/remove-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/remove-button.tsx
index 60eebecd3e5..cc55d525c13 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/remove-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/remove-button.tsx
@@ -5,6 +5,7 @@ import { Button, Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useDispatch, select } from '@wordpress/data';
import { useState } from 'react';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@@ -24,6 +25,10 @@ export default function RemoveButton( {
const { order, fulfillment, notifyCustomer } = useFulfillmentContext();
const [ isExecuting, setIsExecuting ] = useState< boolean >( false );
const { deleteFulfillment } = useDispatch( FulfillmentStore );
+ const descriptionId = useInstanceId(
+ RemoveButton,
+ 'remove-button-description'
+ ) as string;
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
@@ -68,9 +73,16 @@ export default function RemoveButton( {
onClick={ handleRemoveButtonClick }
isBusy={ isExecuting }
__next40pxDefaultSize
+ aria-describedby={ descriptionId }
+ disabled={ isExecuting }
>
- { __( 'Remove', 'woocommerce' ) }
+ { isExecuting
+ ? __( 'Removing…', 'woocommerce' )
+ : __( 'Remove', 'woocommerce' ) }
</Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __( 'Deletes this fulfillment permanently', 'woocommerce' ) }
+ </span>
{ isOpen && (
<Modal
title={ __( 'Remove fulfillment', 'woocommerce' ) }
@@ -91,6 +103,10 @@ export default function RemoveButton( {
variant="link"
onClick={ closeModal }
__next40pxDefaultSize
+ aria-label={ __(
+ 'Cancel removal and close dialog',
+ 'woocommerce'
+ ) }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
@@ -102,8 +118,11 @@ export default function RemoveButton( {
} }
isBusy={ isExecuting }
__next40pxDefaultSize
+ disabled={ isExecuting }
>
- { __( 'Remove fulfillment', 'woocommerce' ) }
+ { isExecuting
+ ? __( 'Removing…', 'woocommerce' )
+ : __( 'Remove fulfillment', 'woocommerce' ) }
</Button>
</div>
</Modal>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/save-draft-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/save-draft-button.tsx
index 3543e840a92..7dd4df91f11 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/save-draft-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/save-draft-button.tsx
@@ -5,6 +5,7 @@ import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useDispatch, select } from '@wordpress/data';
import { useState } from 'react';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@@ -26,6 +27,10 @@ export default function SaveAsDraftButton( {
const { order, fulfillment, notifyCustomer } = useFulfillmentContext();
const [ isExecuting, setIsExecuting ] = useState( false );
const { saveFulfillment } = useDispatch( FulfillmentStore );
+ const descriptionId = useInstanceId(
+ SaveAsDraftButton,
+ 'save-draft-description'
+ ) as string;
const handleFulfillItems = async () => {
setError( null );
@@ -49,13 +54,25 @@ export default function SaveAsDraftButton( {
};
return (
- <Button
- variant="secondary"
- onClick={ handleFulfillItems }
- __next40pxDefaultSize
- isBusy={ isExecuting }
- >
- { __( 'Save as draft', 'woocommerce' ) }
- </Button>
+ <>
+ <Button
+ variant="secondary"
+ onClick={ handleFulfillItems }
+ __next40pxDefaultSize
+ isBusy={ isExecuting }
+ disabled={ isExecuting }
+ aria-describedby={ descriptionId }
+ >
+ { isExecuting
+ ? __( 'Saving…', 'woocommerce' )
+ : __( 'Save as draft', 'woocommerce' ) }
+ </Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __(
+ 'Saves the fulfillment without marking items as fulfilled',
+ 'woocommerce'
+ ) }
+ </span>
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/cancel-link.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/cancel-link.test.js
index 1d0f4810720..8ccaeb1524d 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/cancel-link.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/cancel-link.test.js
@@ -21,4 +21,51 @@ describe( 'CancelLink component', () => {
fireEvent.click( screen.getByText( 'Cancel' ) );
expect( mockOnClick ).toHaveBeenCalledTimes( 1 );
} );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <CancelLink onClick={ () => {} } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <CancelLink onClick={ () => {} } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^cancel-link-description/
+ );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <CancelLink onClick={ () => {} } /> );
+
+ const description = screen.getByText(
+ 'Cancels the current operation without saving changes'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^cancel-link-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ const mockOnClick = jest.fn();
+ render( <CancelLink onClick={ mockOnClick } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
+
+ it( 'should have correct styling for flex layout', () => {
+ render( <CancelLink onClick={ () => {} } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).toHaveStyle( { flex: '1' } );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/edit-fulfillment-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/edit-fulfillment-button.test.js
index b3e0e13d8e9..1cd44eb31de 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/edit-fulfillment-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/edit-fulfillment-button.test.js
@@ -21,4 +21,44 @@ describe( 'EditFulfillmentButton component', () => {
fireEvent.click( screen.getByText( 'Edit fulfillment' ) );
expect( mockOnClick ).toHaveBeenCalledTimes( 1 );
} );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <EditFulfillmentButton onClick={ () => {} } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <EditFulfillmentButton onClick={ () => {} } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^edit-fulfillment-description/
+ );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <EditFulfillmentButton onClick={ () => {} } /> );
+
+ const description = screen.getByText(
+ 'Opens the fulfillment editor to modify fulfillment details'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^edit-fulfillment-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ const mockOnClick = jest.fn();
+ render( <EditFulfillmentButton onClick={ mockOnClick } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/fulfill-items-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/fulfill-items-button.test.js
index c1121c38c6a..c8902f04c89 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/fulfill-items-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/fulfill-items-button.test.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useDispatch } from '@wordpress/data';
/**
@@ -77,15 +77,19 @@ describe( 'FulfillItemsButton component', () => {
} );
render( <FulfillItemsButton setError={ setError } /> );
+
fireEvent.click( screen.getByText( 'Fulfill items' ) );
+ await waitFor( () => {
+ expect( mockSaveFulfillment ).toHaveBeenCalledWith(
+ 123,
+ mockFulfillment,
+ true
+ );
+ } );
+
expect( mockFulfillment.is_fulfilled ).toBe( true );
expect( mockFulfillment.status ).toBe( 'fulfilled' );
- expect( await mockSaveFulfillment ).toHaveBeenCalledWith(
- 123,
- mockFulfillment,
- true
- );
} );
it( 'should not call saveFulfillment when fulfillment is undefined', () => {
@@ -103,4 +107,90 @@ describe( 'FulfillItemsButton component', () => {
expect( mockSaveFulfillment ).not.toHaveBeenCalled();
} );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <FulfillItemsButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <FulfillItemsButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^fulfill-items-description/
+ );
+ } );
+
+ it( 'should not have redundant aria-live on button element', () => {
+ render( <FulfillItemsButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-live' );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <FulfillItemsButton setError={ setError } /> );
+
+ const description = screen.getByText(
+ 'Marks the selected items as fulfilled and updates their status'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^fulfill-items-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should update button text when executing', () => {
+ const mockSaveFulfillment = jest.fn(
+ () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) )
+ );
+ useDispatch.mockReturnValue( {
+ saveFulfillment: mockSaveFulfillment,
+ } );
+
+ const mockFulfillment = {
+ id: 456,
+ meta_data: [
+ {
+ id: 1,
+ key: '_items',
+ value: [
+ {
+ id: 1,
+ name: 'Item 1',
+ quantity: 2,
+ },
+ ],
+ },
+ ],
+ };
+ useFulfillmentContext.mockReturnValue( {
+ order: { id: 123 },
+ fulfillment: mockFulfillment,
+ notifyCustomer: true,
+ } );
+
+ render( <FulfillItemsButton setError={ setError } /> );
+ const button = screen.getByRole( 'button' );
+
+ fireEvent.click( button );
+
+ // Check that the button text updates during execution
+ expect( screen.getByText( 'Fulfilling…' ) ).toBeInTheDocument();
+ expect( button ).toBeDisabled();
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ render( <FulfillItemsButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/remove-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/remove-button.test.js
index 33df88df6a8..ad217959463 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/remove-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/remove-button.test.js
@@ -1,7 +1,8 @@
/**
* External dependencies
*/
-import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useDispatch } from '@wordpress/data';
/**
@@ -24,6 +25,44 @@ jest.mock( '../../../context/fulfillment-context', () => ( {
useFulfillmentContext: jest.fn(),
} ) );
+jest.mock( '../../../context/drawer-context', () => ( {
+ useFulfillmentDrawerContext: jest.fn( () => ( {
+ setIsEditing: jest.fn(),
+ setOpenSection: jest.fn(),
+ } ) ),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ Button: ( { onClick, children, disabled, isBusy, ...props } ) => {
+ // Filter out custom WordPress props that shouldn't be on DOM elements
+ const { variant, __next40pxDefaultSize, ...domProps } = props;
+ return (
+ <button
+ onClick={ onClick }
+ disabled={ disabled || isBusy }
+ { ...domProps }
+ >
+ { children }
+ </button>
+ );
+ },
+ Modal: ( { title, onRequestClose, children } ) => (
+ <div role="dialog" aria-labelledby="modal-title">
+ <h1 id="modal-title">{ title }</h1>
+ { children }
+ <button onClick={ onRequestClose }>Close</button>
+ </div>
+ ),
+ ToggleControl: React.forwardRef( ( { checked, onChange }, ref ) => (
+ <input
+ ref={ ref }
+ type="checkbox"
+ checked={ checked }
+ onChange={ ( e ) => onChange( e.target.checked ) }
+ />
+ ) ),
+} ) );
+
const setError = jest.fn();
describe( 'RemoveButton component', () => {
@@ -104,11 +143,13 @@ describe( 'RemoveButton component', () => {
fireEvent.click( screen.getByText( 'Remove' ) );
- expect( await mockDeleteFulfillment ).toHaveBeenCalledWith(
- 123,
- 456,
- true
- );
+ await waitFor( () => {
+ expect( mockDeleteFulfillment ).toHaveBeenCalledWith(
+ 123,
+ 456,
+ true
+ );
+ } );
} );
it( 'should open confirmation modal when button is clicked on fulfilled fulfillment', async () => {
@@ -137,12 +178,142 @@ describe( 'RemoveButton component', () => {
// Simulate confirmation
fireEvent.click(
- screen.getByRole( 'button', { name: 'Remove fulfillment' } )
- );
- expect( await mockDeleteFulfillment ).toHaveBeenCalledWith(
- 123,
- 456,
- true
+ screen.getByRole( 'button', {
+ name: 'Remove fulfillment',
+ } )
);
+
+ await waitFor( () => {
+ expect( mockDeleteFulfillment ).toHaveBeenCalledWith(
+ 123,
+ 456,
+ true
+ );
+ } );
+ } );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <RemoveButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <RemoveButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^remove-button-description/
+ );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <RemoveButton setError={ setError } /> );
+
+ const description = screen.getByText(
+ 'Deletes this fulfillment permanently'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^remove-button-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should update button text when executing', () => {
+ const mockDeleteFulfillment = jest.fn(
+ () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) )
+ );
+ useDispatch.mockReturnValue( {
+ deleteFulfillment: mockDeleteFulfillment,
+ } );
+
+ render( <RemoveButton setError={ setError } /> );
+ const button = screen.getByRole( 'button' );
+
+ fireEvent.click( button );
+
+ // Check that the button text updates during execution
+ expect( screen.getByText( 'Removing…' ) ).toBeInTheDocument();
+ expect( button ).toBeDisabled();
+ } );
+
+ describe( 'Modal Accessibility', () => {
+ beforeEach( () => {
+ useFulfillmentContext.mockReturnValue( {
+ order: { id: 123 },
+ fulfillment: { id: 456, is_fulfilled: true },
+ notifyCustomer: true,
+ } );
+ } );
+
+ it( 'should have proper modal title', () => {
+ render( <RemoveButton setError={ setError } /> );
+ fireEvent.click( screen.getByText( 'Remove' ) );
+
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Remove fulfillment',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should have accessible cancel button in modal', () => {
+ render( <RemoveButton setError={ setError } /> );
+ fireEvent.click( screen.getByText( 'Remove' ) );
+
+ const cancelButton = screen.getByRole( 'button', {
+ name: 'Cancel removal and close dialog',
+ } );
+ expect( cancelButton ).toBeInTheDocument();
+ expect( cancelButton ).toHaveAttribute(
+ 'aria-label',
+ 'Cancel removal and close dialog'
+ );
+ } );
+
+ it( 'should have accessible confirm button in modal with visible text', () => {
+ render( <RemoveButton setError={ setError } /> );
+ fireEvent.click( screen.getByText( 'Remove' ) );
+
+ const confirmButton = screen.getByRole( 'button', {
+ name: 'Remove fulfillment',
+ } );
+ expect( confirmButton ).toBeInTheDocument();
+ expect( confirmButton ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should update modal button states when executing deletion', async () => {
+ const mockDeleteFulfillment = jest.fn(
+ () =>
+ new Promise( ( resolve ) => setTimeout( resolve, 100 ) )
+ );
+ useDispatch.mockReturnValue( {
+ deleteFulfillment: mockDeleteFulfillment,
+ } );
+
+ render( <RemoveButton setError={ setError } /> );
+ fireEvent.click( screen.getByText( 'Remove' ) );
+
+ const confirmButton = screen.getByRole( 'button', {
+ name: 'Remove fulfillment',
+ } );
+
+ fireEvent.click( confirmButton );
+
+ // The button text should update immediately
+ expect( screen.getByText( 'Removing…' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ render( <RemoveButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
} );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/save-draft-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/save-draft-button.test.js
index 78846e61682..1a7b5919d81 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/save-draft-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/save-draft-button.test.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useDispatch } from '@wordpress/data';
/**
@@ -98,11 +98,13 @@ describe( 'SaveAsDraftButton component', () => {
render( <SaveAsDraftButton setError={ setError } /> );
fireEvent.click( screen.getByText( 'Save as draft' ) );
- expect( mockSaveFulfillment ).toHaveBeenCalledWith(
- 123,
- mockFulfillment,
- true
- );
+ await waitFor( () => {
+ expect( mockSaveFulfillment ).toHaveBeenCalledWith(
+ 123,
+ mockFulfillment,
+ true
+ );
+ } );
} );
it( 'should not call saveFulfillment when fulfillment is undefined', () => {
@@ -119,4 +121,61 @@ describe( 'SaveAsDraftButton component', () => {
expect( mockSaveFulfillment ).not.toHaveBeenCalled();
} );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <SaveAsDraftButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <SaveAsDraftButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^save-draft-description/
+ );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <SaveAsDraftButton setError={ setError } /> );
+
+ const description = screen.getByText(
+ 'Saves the fulfillment without marking items as fulfilled'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^save-draft-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should update button text when executing', () => {
+ const mockSaveFulfillment = jest.fn(
+ () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) )
+ );
+ useDispatch.mockReturnValue( {
+ saveFulfillment: mockSaveFulfillment,
+ } );
+
+ render( <SaveAsDraftButton setError={ setError } /> );
+ const button = screen.getByRole( 'button' );
+
+ fireEvent.click( button );
+
+ // Check that the button text updates during execution
+ expect( screen.getByText( 'Saving…' ) ).toBeInTheDocument();
+ expect( button ).toBeDisabled();
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ render( <SaveAsDraftButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
index 09442bf1f69..ada1ad37915 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useDispatch } from '@wordpress/data';
/**
@@ -100,11 +100,13 @@ describe( 'UpdateButton component', () => {
render( <UpdateButton setError={ setError } /> );
fireEvent.click( screen.getByText( 'Update' ) );
- expect( await mockUpdateFulfillment ).toHaveBeenCalledWith(
- 123,
- mockFulfillment,
- true
- );
+ await waitFor( () => {
+ expect( mockUpdateFulfillment ).toHaveBeenCalledWith(
+ 123,
+ mockFulfillment,
+ true
+ );
+ } );
} );
it( 'should not call updateFulfillment when fulfillment is undefined', () => {
@@ -123,4 +125,61 @@ describe( 'UpdateButton component', () => {
expect( mockUpdateFulfillment ).not.toHaveBeenCalled();
} );
+
+ describe( 'Accessibility', () => {
+ it( 'should not have redundant aria-label overriding visible text', () => {
+ render( <UpdateButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button ).not.toHaveAttribute( 'aria-label' );
+ } );
+
+ it( 'should have aria-describedby with unique prefix', () => {
+ render( <UpdateButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ expect( button.getAttribute( 'aria-describedby' ) ).toMatch(
+ /^update-button-description/
+ );
+ } );
+
+ it( 'should have hidden description for screen readers', () => {
+ render( <UpdateButton setError={ setError } /> );
+
+ const description = screen.getByText(
+ 'Applies changes to the existing fulfillment'
+ );
+ expect( description ).toBeInTheDocument();
+ expect( description.getAttribute( 'id' ) ).toMatch(
+ /^update-button-description/
+ );
+ expect( description ).toHaveClass( 'screen-reader-text' );
+ } );
+
+ it( 'should update button text when executing', () => {
+ const mockUpdateFulfillment = jest.fn(
+ () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) )
+ );
+ useDispatch.mockReturnValue( {
+ updateFulfillment: mockUpdateFulfillment,
+ } );
+
+ render( <UpdateButton setError={ setError } /> );
+ const button = screen.getByRole( 'button' );
+
+ fireEvent.click( button );
+
+ // Check that the button text updates during execution
+ expect( screen.getByText( 'Updating…' ) ).toBeInTheDocument();
+ expect( button ).toBeDisabled();
+ } );
+
+ it( 'should be keyboard accessible', () => {
+ render( <UpdateButton setError={ setError } /> );
+
+ const button = screen.getByRole( 'button' );
+ button.focus();
+ expect( button.ownerDocument.activeElement ).toBe( button );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
index d9cc18d5a20..460781a355d 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
@@ -5,6 +5,7 @@ import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useDispatch, select } from '@wordpress/data';
import { useState } from 'react';
+import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
@@ -26,6 +27,10 @@ export default function UpdateButton( {
const { order, fulfillment, notifyCustomer } = useFulfillmentContext();
const { updateFulfillment } = useDispatch( FulfillmentStore );
const [ isExecuting, setIsExecuting ] = useState< boolean >( false );
+ const descriptionId = useInstanceId(
+ UpdateButton,
+ 'update-button-description'
+ ) as string;
const handleUpdateFulfillment = async () => {
if ( ! fulfillment || ! order ) {
@@ -56,14 +61,25 @@ export default function UpdateButton( {
};
return (
- <Button
- variant="primary"
- onClick={ handleUpdateFulfillment }
- disabled={ isExecuting }
- isBusy={ isExecuting }
- __next40pxDefaultSize
- >
- { __( 'Update', 'woocommerce' ) }
- </Button>
+ <>
+ <Button
+ variant="primary"
+ onClick={ handleUpdateFulfillment }
+ disabled={ isExecuting }
+ isBusy={ isExecuting }
+ __next40pxDefaultSize
+ aria-describedby={ descriptionId }
+ >
+ { isExecuting
+ ? __( 'Updating…', 'woocommerce' )
+ : __( 'Update', 'woocommerce' ) }
+ </Button>
+ <span id={ descriptionId } className="screen-reader-text">
+ { __(
+ 'Applies changes to the existing fulfillment',
+ 'woocommerce'
+ ) }
+ </span>
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
index e920209d374..1b1c89aa9fc 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import { useEffect, useMemo, useRef } from 'react';
import { ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
@@ -21,27 +22,42 @@ export default function CustomerNotificationBox( {
type: 'fulfill' | 'update' | 'remove';
} ) {
const { notifyCustomer, setNotifyCustomer } = useFulfillmentContext();
+ const toggleRef = useRef< HTMLInputElement >( null );
- const headerStrings = {
- fulfill: __( 'Fulfillment notification', 'woocommerce' ),
- remove: __( 'Removal update', 'woocommerce' ),
- update: __( 'Update notification', 'woocommerce' ),
- };
+ const headerStrings = useMemo( () => {
+ return {
+ fulfill: __( 'Fulfillment notification', 'woocommerce' ),
+ remove: __( 'Removal update', 'woocommerce' ),
+ update: __( 'Update notification', 'woocommerce' ),
+ };
+ }, [] );
- const contentStrings = {
- fulfill: __(
- 'Automatically send an email to the customer when the selected items are fulfilled.',
- 'woocommerce'
- ),
- remove: __(
- 'Automatically send an email to the customer notifying that the fulfillment is cancelled.',
- 'woocommerce'
- ),
- update: __(
- 'Automatically send an email to the customer when the fulfillment is updated.',
- 'woocommerce'
- ),
- };
+ const contentStrings = useMemo( () => {
+ return {
+ fulfill: __(
+ 'Automatically send an email to the customer when the selected items are fulfilled.',
+ 'woocommerce'
+ ),
+ remove: __(
+ 'Automatically send an email to the customer notifying that the fulfillment is cancelled.',
+ 'woocommerce'
+ ),
+ update: __(
+ 'Automatically send an email to the customer when the fulfillment is updated.',
+ 'woocommerce'
+ ),
+ };
+ }, [] );
+
+ const descriptionId = 'notification-description';
+
+ useEffect( () => {
+ if ( toggleRef.current ) {
+ toggleRef.current.ariaLabel =
+ headerStrings[ type ] || headerStrings.fulfill;
+ toggleRef.current.setAttribute( 'aria-describedby', descriptionId );
+ }
+ }, [ type, headerStrings ] );
return (
<FulfillmentCard
@@ -55,7 +71,8 @@ export default function CustomerNotificationBox( {
<ToggleControl
__nextHasNoMarginBottom
checked={ notifyCustomer }
- label={ null }
+ label={ '' }
+ ref={ toggleRef }
onChange={ ( checked ) => {
setNotifyCustomer( checked );
} }
@@ -63,7 +80,10 @@ export default function CustomerNotificationBox( {
</>
}
>
- <p className="woocommerce-fulfillment-description">
+ <p
+ id={ descriptionId }
+ className="woocommerce-fulfillment-description"
+ >
{ contentStrings[ type ] || contentStrings.fulfill }
</p>
</FulfillmentCard>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
index eb48185a3dd..ffbcfaac4fa 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import React from 'react';
import { render, screen } from '@testing-library/react';
/**
@@ -34,16 +35,17 @@ jest.mock( '../../../context/fulfillment-context', () => ( {
// Mock ToggleControl to make testing easier
jest.mock( '@wordpress/components', () => ( {
- ToggleControl: ( props ) => (
+ ToggleControl: React.forwardRef( ( props, ref ) => (
<div data-testid="toggle-control">
<input
+ ref={ ref }
type="checkbox"
checked={ props.checked }
onChange={ () => props.onChange( ! props.checked ) }
data-testid="toggle-input"
/>
</div>
- ),
+ ) ),
} ) );
describe( 'CustomerNotificationBox component', () => {
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-editor.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-editor.tsx
index 2d3bd9856cd..9eea548eb76 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-editor.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-editor.tsx
@@ -1,8 +1,8 @@
/**
* External dependencies
*/
-import { Button, Icon } from '@wordpress/components';
-import { useEffect, useState } from 'react';
+import { Icon } from '@wordpress/components';
+import { useEffect, useState, useRef } from 'react';
import { __, sprintf } from '@wordpress/i18n';
/**
@@ -51,6 +51,7 @@ export default function FulfillmentEditor( {
const { order, fulfillments, refunds } = useFulfillmentDrawerContext();
const { isEditing, setIsEditing } = useFulfillmentDrawerContext();
const [ error, setError ] = useState< string | null >( null );
+ const contentRef = useRef< HTMLDivElement >( null );
const itemsInFulfillment = order
? getItemsFromFulfillment( order, fulfillment )
: [];
@@ -69,6 +70,31 @@ export default function FulfillmentEditor( {
setError( null );
}, [ order?.id ] );
+ // Focus management when entering edit mode
+ useEffect( () => {
+ let rafId1: number;
+ let rafId2: number;
+ if ( isEditing && expanded && contentRef.current ) {
+ const content = contentRef.current;
+ rafId1 = requestAnimationFrame( () => {
+ rafId2 = requestAnimationFrame( () => {
+ // Look for the first interactive element in edit mode
+ const firstEditable = content.querySelector(
+ 'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'
+ ) as HTMLElement;
+
+ if ( firstEditable ) {
+ firstEditable.focus();
+ }
+ } );
+ } );
+ }
+ return () => {
+ cancelAnimationFrame( rafId1 );
+ cancelAnimationFrame( rafId2 );
+ };
+ }, [ isEditing, expanded ] );
+
const handleChevronClick = () => {
if ( isEditing ) return;
if (
@@ -98,13 +124,15 @@ export default function FulfillmentEditor( {
expanded ? 'is-open' : '',
].join( ' ' ) }
onClick={ handleChevronClick }
- onKeyUp={ ( event ) => {
- if ( event.key === 'Enter' ) {
+ onKeyDown={ ( event ) => {
+ if ( event.key === 'Enter' || event.key === ' ' ) {
+ event.preventDefault();
handleChevronClick();
}
} }
role="button"
- tabIndex={ -1 }
+ tabIndex={ 0 }
+ aria-expanded={ expanded }
>
<h3>
{
@@ -122,7 +150,7 @@ export default function FulfillmentEditor( {
<FulfillmentStatusBadge fulfillment={ fulfillment } />
{ ( itemsNotInAnyFulfillment.length > 0 ||
fulfillments.length > 1 ) && (
- <Button __next40pxDefaultSize size="small">
+ <div aria-hidden="true">
<Icon
icon={
expanded ? 'arrow-up-alt2' : 'arrow-down-alt2'
@@ -130,11 +158,14 @@ export default function FulfillmentEditor( {
size={ 16 }
color={ isEditing ? '#dddddd' : undefined }
/>
- </Button>
+ </div>
) }
</div>
{ expanded && (
- <div className="woocommerce-fulfillment-stored-fulfillment-list-item-content">
+ <div
+ className="woocommerce-fulfillment-stored-fulfillment-list-item-content"
+ ref={ contentRef }
+ >
{ error && <ErrorLabel error={ error } /> }
<ShipmentFormProvider fulfillment={ fulfillment }>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-line-item.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-line-item.tsx
index f0f77b63432..e339937e407 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-line-item.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-line-item.tsx
@@ -2,7 +2,8 @@
* External dependencies
*/
import { useContext, useState } from 'react';
-import { CheckboxControl, Icon } from '@wordpress/components';
+import { Button, CheckboxControl, Icon } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
import CurrencyFactory, {
CurrencyContext,
SymbolPosition,
@@ -90,19 +91,33 @@ export default function FulfillmentLineItem( {
} }
indeterminate={ isIndeterminate( item.id ) }
__nextHasNoMarginBottom
+ aria-label={ item.name }
/>
</div>
) }
{ editMode && quantity > 1 && (
- <Icon
- icon={
- itemExpanded ? 'arrow-up-alt2' : 'arrow-down-alt2'
- }
+ <Button
onClick={ () => {
setItemExpanded( ! itemExpanded );
} }
- size={ 16 }
- />
+ aria-label={
+ itemExpanded
+ ? __( 'Collapse item details', 'woocommerce' )
+ : __( 'Expand item details', 'woocommerce' )
+ }
+ aria-expanded={ itemExpanded }
+ className="woocommerce-fulfillment-item-expand-button"
+ >
+ <Icon
+ icon={
+ itemExpanded
+ ? 'arrow-up-alt2'
+ : 'arrow-down-alt2'
+ }
+ aria-hidden="true"
+ size={ 16 }
+ />
+ </Button>
) }
<div className="woocommerce-fulfillment-item-title">
<div className="woocommerce-fulfillment-item-image-container">
@@ -155,6 +170,9 @@ export default function FulfillmentLineItem( {
onChange={ ( value ) => {
toggleItem( item.id, index, value );
} }
+ aria-label={ `${ item.name } - item ${
+ index + 1
+ }` }
__nextHasNoMarginBottom
/>
</div>
@@ -163,7 +181,7 @@ export default function FulfillmentLineItem( {
<div className="woocommerce-fulfillment-item-image-container">
<img
src={ item.image.src }
- alt={ item.name }
+ alt={ '' } // WCAG: gives redundant alt text alert, as item.name is already used in the title.
width={ 32 }
height={ 32 }
className="woocommerce-fulfillment-item-image"
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-status-badge.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-status-badge.tsx
index 99b0c9db0c5..ccdf694058c 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-status-badge.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/fulfillment-status-badge.tsx
@@ -1,3 +1,8 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
/**
* Internal dependencies
*/
@@ -23,6 +28,10 @@ export default function FulfillmentStatusBadge( {
backgroundColor: fulfillmentStatus.background_color,
color: fulfillmentStatus.text_color,
} }
+ role="status"
+ aria-label={ `${ __( 'Fulfillment status:', 'woocommerce' ) } ${
+ fulfillmentStatus.label
+ }` }
>
{ fulfillmentStatus.label }
</div>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/item-selector.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/item-selector.tsx
index e88a8c421d5..cf0accbd298 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/item-selector.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/item-selector.tsx
@@ -2,7 +2,8 @@
* External dependencies
*/
import { CheckboxControl } from '@wordpress/components';
-import { _n, sprintf } from '@wordpress/i18n';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
@@ -40,6 +41,7 @@ export default function ItemSelector( { editMode }: ItemSelectorProps ) {
} ) ),
} ) )
);
+ speak( __( 'All items deselected.', 'woocommerce' ), 'polite' );
};
const selectAllItems = () => {
@@ -52,6 +54,19 @@ export default function ItemSelector( { editMode }: ItemSelectorProps ) {
} ) ),
} ) )
);
+ speak(
+ sprintf(
+ /* translators: %d is the number of selected items */
+ _n(
+ '%d item selected.',
+ '%d items selected.',
+ itemsCount,
+ 'woocommerce'
+ ),
+ itemsCount
+ ),
+ 'polite'
+ );
};
const handleToggleItem = (
@@ -91,6 +106,23 @@ export default function ItemSelector( { editMode }: ItemSelectorProps ) {
return item;
} ),
] );
+
+ const currentItem = selectedItems.find(
+ ( item ) => item.item_id === id
+ );
+ if ( currentItem ) {
+ speak(
+ sprintf(
+ /* translators: %1$s is the item name, %2$s is selected/deselected status */
+ __( '%1$s %2$s.', 'woocommerce' ),
+ currentItem.item.name,
+ checked
+ ? __( 'selected', 'woocommerce' )
+ : __( 'deselected', 'woocommerce' )
+ ),
+ 'polite'
+ );
+ }
};
const isChecked = ( id: number, index: number ) => {
@@ -124,7 +156,10 @@ export default function ItemSelector( { editMode }: ItemSelectorProps ) {
};
return (
- <ul className="woocommerce-fulfillment-item-list">
+ <ul
+ className="woocommerce-fulfillment-item-list"
+ aria-label={ __( 'Select items for fulfillment', 'woocommerce' ) }
+ >
<li>
<div className="woocommerce-fulfillment-item-bulk-select">
{ editMode && (
@@ -141,6 +176,11 @@ export default function ItemSelector( { editMode }: ItemSelectorProps ) {
selectedItemsCount > 0 &&
selectedItemsCount < itemsCount
}
+ aria-label={
+ selectedItemsCount === itemsCount
+ ? __( 'Deselect all items', 'woocommerce' )
+ : __( 'Select all items', 'woocommerce' )
+ }
__nextHasNoMarginBottom
/>
) }
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/new-fulfillment-form.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/new-fulfillment-form.tsx
index 444e02b2d04..61372bfddee 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/new-fulfillment-form.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/new-fulfillment-form.tsx
@@ -3,7 +3,7 @@
*/
import { useEffect, useMemo, useState } from 'react';
import { __ } from '@wordpress/i18n';
-import { Button, Icon } from '@wordpress/components';
+import { Icon } from '@wordpress/components';
/**
* Internal dependencies
@@ -87,14 +87,21 @@ const NewFulfillmentForm: React.FC = () => {
onKeyDown={ ( event ) => {
if ( fulfillments.length > 0 ) {
if ( event.key === 'Enter' || event.key === ' ' ) {
+ event.preventDefault();
setOpenSection(
openSection === 'order' ? '' : 'order'
);
}
}
} }
- tabIndex={ 0 }
+ tabIndex={ fulfillments.length > 0 ? 0 : -1 }
role="button"
+ aria-expanded={ openSection === 'order' }
+ aria-label={
+ openSection === 'order'
+ ? __( 'Collapse pending items', 'woocommerce' )
+ : __( 'Expand pending items', 'woocommerce' )
+ }
>
<h3>
{ fulfillments.length === 0
@@ -102,7 +109,7 @@ const NewFulfillmentForm: React.FC = () => {
: __( 'Pending Items', 'woocommerce' ) }
</h3>
{ fulfillments.length > 0 && (
- <Button __next40pxDefaultSize size="small">
+ <div aria-hidden="true">
<Icon
icon={
openSection === 'order'
@@ -111,7 +118,7 @@ const NewFulfillmentForm: React.FC = () => {
}
size={ 16 }
/>
- </Button>
+ </div>
) }
</div>
{ ! isEditing && openSection === 'order' && (
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-editor.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-editor.test.js
index 28d9115fa0b..edeaaa35595 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-editor.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-editor.test.js
@@ -273,4 +273,19 @@ describe( 'FulfillmentEditor', () => {
screen.getByText( 'This fulfillment is locked.' )
).toBeInTheDocument();
} );
+
+ it( 'should render content when expanded', () => {
+ render( <FulfillmentEditor { ...mockProps } expanded={ true } /> );
+
+ // Verify expanded content is rendered
+ expect(
+ screen.getByTestId( 'edit-fulfillment-button' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId( 'fulfill-items-button' )
+ ).toBeInTheDocument();
+
+ // Verify the component renders without errors when expanded
+ expect( screen.getByText( 'Fulfillment #1' ) ).toBeInTheDocument();
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-line-item.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-line-item.test.js
index aef8b7e7107..3f2f2b899ed 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-line-item.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/fulfillment-line-item.test.js
@@ -18,6 +18,11 @@ jest.mock( '@wordpress/components', () => ( {
onChange={ ( e ) => onChange( e.target.checked ) }
/>
),
+ Button: ( { onClick, children, ...props } ) => (
+ <button onClick={ onClick } { ...props }>
+ { children }
+ </button>
+ ),
Icon: ( { icon, onClick } ) => (
<div
role="button"
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/item-selector.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/item-selector.test.js
new file mode 100644
index 00000000000..9d6bf198dfb
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/fulfillments/test/item-selector.test.js
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+import { speak } from '@wordpress/a11y';
+
+/**
+ * Internal dependencies
+ */
+import '../../../test-helper/global-mock';
+import ItemSelector from '../item-selector';
+import { useFulfillmentContext } from '../../../context/fulfillment-context';
+
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
+jest.mock( '../../../context/fulfillment-context', () => ( {
+ useFulfillmentContext: jest.fn(),
+} ) );
+
+const createMockItems = ( items ) =>
+ items.map( ( item ) => ( {
+ item_id: item.id,
+ item: { id: item.id, name: item.name, quantity: item.qty },
+ selection: Array.from( { length: item.qty }, ( _, i ) => ( {
+ index: i,
+ checked: item.checked ?? false,
+ } ) ),
+ } ) );
+
+describe( 'ItemSelector speak() announcements', () => {
+ let mockSetSelectedItems;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockSetSelectedItems = jest.fn();
+ } );
+
+ it( 'should announce when all items are selected', () => {
+ const items = createMockItems( [
+ { id: 1, name: 'Widget', qty: 2, checked: false },
+ { id: 2, name: 'Gadget', qty: 1, checked: false },
+ ] );
+
+ useFulfillmentContext.mockReturnValue( {
+ order: { id: 1, currency: 'USD' },
+ selectedItems: items,
+ setSelectedItems: mockSetSelectedItems,
+ } );
+
+ render( <ItemSelector editMode={ true } /> );
+
+ // Click the select-all checkbox
+ const selectAllCheckbox = screen.getByRole( 'checkbox', {
+ name: 'Select all items',
+ } );
+ fireEvent.click( selectAllCheckbox );
+
+ expect( speak ).toHaveBeenCalledWith( '3 items selected.', 'polite' );
+ } );
+
+ it( 'should announce when all items are deselected', () => {
+ const items = createMockItems( [
+ { id: 1, name: 'Widget', qty: 2, checked: true },
+ { id: 2, name: 'Gadget', qty: 1, checked: true },
+ ] );
+
+ useFulfillmentContext.mockReturnValue( {
+ order: { id: 1, currency: 'USD' },
+ selectedItems: items,
+ setSelectedItems: mockSetSelectedItems,
+ } );
+
+ render( <ItemSelector editMode={ true } /> );
+
+ // Click the deselect-all checkbox (all items are already selected)
+ const deselectAllCheckbox = screen.getByRole( 'checkbox', {
+ name: 'Deselect all items',
+ } );
+ fireEvent.click( deselectAllCheckbox );
+
+ expect( speak ).toHaveBeenCalledWith(
+ 'All items deselected.',
+ 'polite'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
index 282d5ae0db3..cf1126d506b 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-manual-entry-form.tsx
@@ -27,7 +27,7 @@ const ShippingProviderListItem = ( {
>
{ item.icon && (
<div className="woocommerce-fulfillment-shipping-provider-list-item-icon">
- <img src={ item.icon } alt={ item.label } />
+ <img src={ item.icon } alt="" aria-hidden="true" />
</div>
) }
<div className="woocommerce-fulfillment-shipping-provider-list-item-label">
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
index e7dc1e3442b..170c9a327e0 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/shipment-tracking-number-form.tsx
@@ -3,9 +3,11 @@
*/
import { Button, ExternalLink, Flex, TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { useInstanceId } from '@wordpress/compose';
import { isEmpty } from 'lodash';
import apiFetch from '@wordpress/api-fetch';
+import { speak } from '@wordpress/a11y';
import { addQueryArgs } from '@wordpress/url';
/**
@@ -40,7 +42,11 @@ const ShipmentProviderIcon = ( { providerKey }: { providerKey: string } ) => {
return (
<div className="woocommerce-fulfillment-shipment-provider-icon">
- <img src={ icon } alt={ provider.label } key={ providerKey } />
+ <img
+ src={ icon }
+ alt={ `${ provider.label } shipping provider logo` }
+ key={ providerKey }
+ />
</div>
);
};
@@ -51,7 +57,20 @@ export default function ShipmentTrackingNumberForm() {
const [ error, setError ] = useState< string | null >( null );
const [ editMode, setEditMode ] = useState( false );
const [ isLoading, setIsLoading ] = useState( false );
+ const inputRef = useRef< HTMLInputElement >( null );
const { order } = useFulfillmentContext();
+ const trackingNumberErrorId = useInstanceId(
+ ShipmentTrackingNumberForm,
+ 'tracking-number-error'
+ ) as string;
+ const findingStatusId = useInstanceId(
+ ShipmentTrackingNumberForm,
+ 'finding-status'
+ ) as string;
+ const providerAmbiguityNoticeId = useInstanceId(
+ ShipmentTrackingNumberForm,
+ 'provider-ambiguity-notice'
+ ) as string;
const {
trackingNumber,
setTrackingNumber,
@@ -83,12 +102,12 @@ export default function ShipmentTrackingNumberForm() {
method: 'GET',
} );
if ( ! tracking_number_response.tracking_number ) {
- setError(
- __(
- 'No information found for this tracking number. Check the number or enter the details manually.',
- 'woocommerce'
- )
+ const errorMessage = __(
+ 'No information found for this tracking number. Check the number or enter the details manually.',
+ 'woocommerce'
);
+ setError( errorMessage );
+ speak( errorMessage, 'assertive' );
return;
}
@@ -122,10 +141,21 @@ export default function ShipmentTrackingNumberForm() {
setShipmentProvider( tracking_number_response.shipping_provider );
setProviderName( '' );
setEditMode( false );
+
+ const successMessage = __(
+ 'Tracking information found successfully.',
+ 'woocommerce'
+ );
+ speak( successMessage, 'polite' );
} catch ( err ) {
- setError(
- __( 'Failed to fetch shipment information.', 'woocommerce' )
+ // eslint-disable-next-line no-console
+ console.error( 'Tracking number lookup failed:', err );
+ const errorMessage = __(
+ 'Failed to fetch shipment information.',
+ 'woocommerce'
);
+ setError( errorMessage );
+ speak( errorMessage, 'assertive' );
} finally {
setIsLoading( false );
}
@@ -137,6 +167,17 @@ export default function ShipmentTrackingNumberForm() {
}
}, [ trackingNumber ] );
+ useEffect( () => {
+ if ( editMode && inputRef.current ) {
+ inputRef.current.focus();
+ }
+ }, [ editMode ] );
+
+ const handleEditModeToggle = () => {
+ setEditMode( true );
+ setTrackingNumberTemp( trackingNumber );
+ };
+
return (
<>
<p className="woocommerce-fulfillment-description">
@@ -149,6 +190,7 @@ export default function ShipmentTrackingNumberForm() {
<div className="woocommerce-fulfillment-input-container">
<div className="woocommerce-fulfillment-input-group">
<TextControl
+ ref={ inputRef }
type="text"
label={ __( 'Tracking Number', 'woocommerce' ) }
placeholder={ __(
@@ -158,6 +200,9 @@ export default function ShipmentTrackingNumberForm() {
value={ trackingNumberTemp }
onChange={ ( value ) => {
setTrackingNumberTemp( value );
+ if ( error ) {
+ setError( null );
+ }
} }
onKeyDown={ ( event ) => {
if (
@@ -168,20 +213,43 @@ export default function ShipmentTrackingNumberForm() {
handleTrackingNumberLookup();
}
} }
+ aria-invalid={ !! error }
+ aria-describedby={
+ error ? trackingNumberErrorId : undefined
+ }
+ autoComplete="off"
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
<Button
variant="secondary"
- text="Find info"
+ text={
+ isLoading
+ ? __( 'Finding…', 'woocommerce' )
+ : __( 'Find info', 'woocommerce' )
+ }
disabled={
isLoading ||
isEmpty( trackingNumberTemp.trim() )
}
isBusy={ isLoading }
onClick={ handleTrackingNumberLookup }
+ aria-describedby={
+ isLoading ? findingStatusId : undefined
+ }
__next40pxDefaultSize
/>
+ { isLoading && (
+ <span
+ id={ findingStatusId }
+ className="screen-reader-text"
+ >
+ { __(
+ 'Searching for tracking information…',
+ 'woocommerce'
+ ) }
+ </span>
+ ) }
</div>
</div>
) : (
@@ -190,10 +258,7 @@ export default function ShipmentTrackingNumberForm() {
<h4>{ __( 'Tracking Number', 'woocommerce' ) }</h4>
<div className="woocommerce-fulfillment-input-group space-between">
<span
- onClick={ () => {
- setEditMode( true );
- setTrackingNumberTemp( trackingNumber );
- } }
+ onClick={ handleEditModeToggle }
role="button"
tabIndex={ 0 }
onKeyDown={ ( event ) => {
@@ -201,20 +266,24 @@ export default function ShipmentTrackingNumberForm() {
event.key === 'Enter' ||
event.key === ' '
) {
- setEditMode( true );
- setTrackingNumberTemp( trackingNumber );
+ handleEditModeToggle();
}
} }
style={ { cursor: 'pointer' } }
+ aria-label={ __(
+ 'Edit tracking number',
+ 'woocommerce'
+ ) }
>
{ trackingNumber }
</span>
<Button
size="small"
- onClick={ () => {
- setEditMode( true );
- setTrackingNumberTemp( trackingNumber );
- } }
+ aria-label={ __(
+ 'Edit tracking number',
+ 'woocommerce'
+ ) }
+ onClick={ handleEditModeToggle }
>
<EditIcon />
</Button>
@@ -236,7 +305,10 @@ export default function ShipmentTrackingNumberForm() {
</div>
{ isAmbiguousProvider && (
<Flex direction={ 'column' } gap={ 0 }>
- <p className="woocommerce-fulfillment-description">
+ <p
+ className="woocommerce-fulfillment-description"
+ id={ providerAmbiguityNoticeId }
+ >
{ __(
'Not your provider?',
'woocommerce'
@@ -250,7 +322,17 @@ export default function ShipmentTrackingNumberForm() {
setSelectedOption(
SHIPMENT_OPTION_MANUAL_ENTRY
);
+ speak(
+ __(
+ 'Switched to manual provider selection.',
+ 'woocommerce'
+ ),
+ 'polite'
+ );
} }
+ aria-describedby={
+ providerAmbiguityNoticeId
+ }
>
{ __(
'Select your provider manually',
@@ -278,7 +360,11 @@ export default function ShipmentTrackingNumberForm() {
</div>
</>
) }
- { error && <ErrorLabel error={ error } /> }
+ { error && (
+ <div id={ trackingNumberErrorId } role="alert">
+ <ErrorLabel error={ error } />
+ </div>
+ ) }
</>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
index b1a307a62ff..1ee7e96d436 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/shipment-form/test/shipment-tracking-number-form.test.js
@@ -1,8 +1,10 @@
/**
* External dependencies
*/
+import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import apiFetch from '@wordpress/api-fetch';
+import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
@@ -12,6 +14,10 @@ import ShipmentTrackingNumberForm from '../shipment-tracking-number-form';
import { useShipmentFormContext } from '../../../context/shipment-form-context';
import { SHIPMENT_OPTION_MANUAL_ENTRY } from '../../../data/constants';
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
jest.mock( '../../../context/shipment-form-context', () => ( {
useShipmentFormContext: jest.fn(),
} ) );
@@ -24,16 +30,19 @@ jest.mock( '@wordpress/api-fetch' );
jest.mock( '@wordpress/components', () => ( {
...jest.requireActual( '@wordpress/components' ),
- TextControl: ( { value, onChange, placeholder, onKeyDown } ) => (
- <div data-testid="text-control">
- <input
- type="text"
- value={ value }
- placeholder={ placeholder }
- onChange={ ( e ) => onChange( e.target.value ) }
- onKeyDown={ onKeyDown }
- />
- </div>
+ TextControl: React.forwardRef(
+ ( { value, onChange, placeholder, onKeyDown }, ref ) => (
+ <div data-testid="text-control">
+ <input
+ ref={ ref }
+ type="text"
+ value={ value }
+ placeholder={ placeholder }
+ onChange={ ( e ) => onChange( e.target.value ) }
+ onKeyDown={ onKeyDown }
+ />
+ </div>
+ )
),
} ) );
@@ -111,11 +120,24 @@ describe( 'ShipmentTrackingNumberForm', () => {
fireEvent.change( input, { target: { value: 'invalid' } } );
fireEvent.click( screen.getByText( 'Find info' ) );
await waitFor( () => {
- expect(
- screen.getByText(
- 'No information found for this tracking number. Check the number or enter the details manually.'
- )
- ).toBeInTheDocument();
+ // Check for the error container with proper ARIA attributes
+ const errorContainer = screen.getByRole( 'alert' );
+ expect( errorContainer ).toBeInTheDocument();
+ // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions
+ expect( errorContainer.getAttribute( 'id' ) ).toMatch(
+ /^tracking-number-error/
+ );
+ // role="alert" implicitly sets aria-live="assertive", so explicit aria-live should not be present
+ // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions
+ expect( errorContainer ).not.toHaveAttribute( 'aria-live' );
+
+ // Check that the error message is within the error label component
+ const errorLabel = screen.getByText(
+ 'No information found for this tracking number. Check the number or enter the details manually.',
+ { selector: '.woocommerce-fulfillment-error-label__text' }
+ );
+ // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions
+ expect( errorLabel ).toBeInTheDocument();
} );
} );
@@ -167,16 +189,37 @@ describe( 'ShipmentTrackingNumberForm', () => {
it( 'switches to edit mode when tracking number is clicked', () => {
mockContext.trackingNumber = '1Z12345E0291980793';
render( <ShipmentTrackingNumberForm /> );
- const trackingNumberSpan = screen.getByRole( 'button', {
- name: '1Z12345E0291980793',
- } );
- fireEvent.click( trackingNumberSpan );
+ const editElements = screen.getAllByLabelText( 'Edit tracking number' );
+ fireEvent.click( editElements[ 0 ] ); // Click the first element (span)
expect(
screen.getByPlaceholderText( 'Enter tracking number' )
).toBeInTheDocument();
} );
+ it( 'focuses input when tracking number label is clicked', () => {
+ mockContext.trackingNumber = '1Z12345E0291980793';
+ const { container } = render( <ShipmentTrackingNumberForm /> );
+
+ const trackingNumberSpan = screen.getAllByLabelText(
+ 'Edit tracking number'
+ )[ 0 ];
+ fireEvent.click( trackingNumberSpan );
+
+ const input = container.querySelector( 'input' );
+ expect( input ).toHaveFocus();
+ } );
+
+ it( 'focuses input when edit button is clicked', () => {
+ mockContext.trackingNumber = '1Z12345E0291980793';
+ const { container } = render( <ShipmentTrackingNumberForm /> );
+
+ fireEvent.click( screen.getByTestId( 'edit-icon' ) );
+
+ const input = container.querySelector( 'input' );
+ expect( input ).toHaveFocus();
+ } );
+
it( 'shows ambiguous provider message when possibilities have low confidence scores', async () => {
mockContext.trackingNumber = '';
mockContext.shipmentProvider = '';
@@ -357,4 +400,115 @@ describe( 'ShipmentTrackingNumberForm', () => {
screen.queryByText( 'Not your provider?' )
).not.toBeInTheDocument();
} );
+
+ describe( 'speak() announcements', () => {
+ it( 'should announce success on valid tracking number lookup', async () => {
+ mockContext.trackingNumber = '';
+ mockContext.shipmentProvider = '';
+ apiFetch.mockResolvedValueOnce( {
+ tracking_number: '1Z12345E0291980793',
+ shipping_provider: 'ups',
+ tracking_url:
+ 'https://www.ups.com/track?tracknum=1Z12345E0291980793',
+ } );
+
+ render( <ShipmentTrackingNumberForm /> );
+ const input = screen.getByPlaceholderText(
+ 'Enter tracking number'
+ );
+ fireEvent.change( input, {
+ target: { value: '1Z12345E0291980793' },
+ } );
+ fireEvent.click( screen.getByText( 'Find info' ) );
+
+ await waitFor( () => {
+ expect( speak ).toHaveBeenCalledWith(
+ 'Tracking information found successfully.',
+ 'polite'
+ );
+ } );
+ } );
+
+ it( 'should announce error on invalid tracking number lookup', async () => {
+ mockContext.trackingNumber = '';
+ mockContext.shipmentProvider = '';
+ apiFetch.mockResolvedValueOnce( {} );
+
+ render( <ShipmentTrackingNumberForm /> );
+ const input = screen.getByPlaceholderText(
+ 'Enter tracking number'
+ );
+ fireEvent.change( input, { target: { value: 'invalid' } } );
+ fireEvent.click( screen.getByText( 'Find info' ) );
+
+ await waitFor( () => {
+ expect( speak ).toHaveBeenCalledWith(
+ 'No information found for this tracking number. Check the number or enter the details manually.',
+ 'assertive'
+ );
+ } );
+ } );
+
+ it( 'should announce error on API failure', async () => {
+ mockContext.trackingNumber = '';
+ mockContext.shipmentProvider = '';
+ apiFetch.mockRejectedValueOnce( new Error( 'Network error' ) );
+
+ render( <ShipmentTrackingNumberForm /> );
+ const input = screen.getByPlaceholderText(
+ 'Enter tracking number'
+ );
+ fireEvent.change( input, { target: { value: '12345' } } );
+ fireEvent.click( screen.getByText( 'Find info' ) );
+
+ await waitFor( () => {
+ expect( speak ).toHaveBeenCalledWith(
+ 'Failed to fetch shipment information.',
+ 'assertive'
+ );
+ } );
+ } );
+
+ it( 'should announce when switching to manual provider selection', async () => {
+ mockContext.trackingNumber = '';
+ mockContext.shipmentProvider = '';
+ apiFetch.mockResolvedValueOnce( {
+ tracking_number: '1234567890123456',
+ shipping_provider: 'ups',
+ tracking_url:
+ 'https://www.ups.com/track?tracknum=1234567890123456',
+ possibilities: {
+ ups: { url: 'https://ups.com', ambiguity_score: 70 },
+ fedex: { url: 'https://fedex.com', ambiguity_score: 75 },
+ },
+ } );
+
+ render( <ShipmentTrackingNumberForm /> );
+ const input = screen.getByPlaceholderText(
+ 'Enter tracking number'
+ );
+ fireEvent.change( input, {
+ target: { value: '1234567890123456' },
+ } );
+ fireEvent.click( screen.getByText( 'Find info' ) );
+
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Not your provider?' )
+ ).toBeInTheDocument();
+ } );
+
+ // Clear speak mock from the lookup call
+ speak.mockClear();
+
+ fireEvent.click(
+ screen.getByText( 'Select your provider manually' )
+ );
+
+ expect( speak ).toHaveBeenCalledWith(
+ 'Switched to manual provider selection.',
+ 'polite'
+ );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/error-label.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/error-label.tsx
index 1b490551303..9118a41244e 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/error-label.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/error-label.tsx
@@ -17,13 +17,17 @@ export default function ErrorLabel( { error }: { error: string } ) {
}, [ error ] );
return (
<div className="woocommerce-fulfillment-error-label" ref={ labelRef }>
- <span className="woocommerce-fulfillment-error-label__icon">
+ <span
+ className="woocommerce-fulfillment-error-label__icon"
+ aria-hidden="true"
+ >
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
>
<path
d="M7.99996 13.3333C10.9455 13.3333 13.3333 10.9455 13.3333 7.99996C13.3333 5.05444 10.9455 2.66663 7.99996 2.66663C5.05444 2.66663 2.66663 5.05444 2.66663 7.99996C2.66663 10.9455 5.05444 13.3333 7.99996 13.3333Z"
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer-header.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer-header.tsx
index 2d7411dd8fa..babe3a5167c 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer-header.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer-header.tsx
@@ -2,6 +2,7 @@
* External dependencies
*/
import moment from 'moment';
+import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
@@ -21,9 +22,12 @@ export default function FulfillmentsDrawerHeader( {
return (
order && (
- <div className={ 'woocommerce-fulfillment-drawer__header' }>
+ <div
+ className={ 'woocommerce-fulfillment-drawer__header' }
+ tabIndex={ -1 }
+ >
<div className="woocommerce-fulfillment-drawer__header__title">
- <h2>
+ <h2 id="fulfillment-drawer-header">
#{ order.id }{ ' ' }
{ order.billing.first_name +
' ' +
@@ -36,6 +40,10 @@ export default function FulfillmentsDrawerHeader( {
setOpenSection( 'order' );
onClose();
} }
+ aria-label={ __(
+ 'Close fulfillment drawer',
+ 'woocommerce'
+ ) }
>
×
</button>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer.tsx
index 71c31439ffd..6fdec955b7f 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/fulfillment-drawer.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
/**
* Internal dependencies
@@ -27,6 +27,95 @@ const FulfillmentDrawer: React.FC< Props > = ( {
onClose,
orderId,
} ) => {
+ const drawerRef = useRef< HTMLDivElement >( null );
+ const previousFocusRef = useRef< HTMLElement | null >( null );
+
+ // Focus management when drawer opens/closes
+ useEffect( () => {
+ let rafId1: number;
+ let rafId2: number;
+ if ( isOpen ) {
+ const drawerElement = drawerRef.current;
+ if ( drawerElement ) {
+ // Save the previous focused element to restore focus later
+ previousFocusRef.current = drawerElement.ownerDocument
+ .activeElement as HTMLElement;
+
+ // Focus the drawer container itself after it's fully rendered
+ // This allows natural scrolling and keyboard navigation within
+ rafId1 = requestAnimationFrame( () => {
+ rafId2 = requestAnimationFrame( () => {
+ if ( drawerElement ) {
+ drawerElement.focus();
+ }
+ } );
+ } );
+ }
+ } else if ( previousFocusRef.current?.isConnected ) {
+ // Restore focus to the previously focused element
+ previousFocusRef.current.focus();
+ }
+ return () => {
+ cancelAnimationFrame( rafId1 );
+ cancelAnimationFrame( rafId2 );
+ };
+ }, [ isOpen ] );
+
+ // Handle keyboard navigation: Escape to close and focus trapping
+ useEffect( () => {
+ const handleKeyDown = ( event: KeyboardEvent ) => {
+ if ( ! isOpen ) return;
+
+ // Close drawer on Escape key
+ if ( event.key === 'Escape' ) {
+ onClose();
+ return;
+ }
+
+ // Focus trap: Only trap Tab navigation, allow all other keys (including scrolling)
+ if ( event.key === 'Tab' ) {
+ const drawerElement = drawerRef.current;
+ if ( ! drawerElement ) return;
+
+ const focusableElements = drawerElement.querySelectorAll(
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'
+ );
+
+ if ( focusableElements.length === 0 ) return;
+
+ const firstElement = focusableElements[ 0 ] as HTMLElement;
+ const lastElement = focusableElements[
+ focusableElements.length - 1
+ ] as HTMLElement;
+ const activeElement = drawerElement.ownerDocument
+ .activeElement as HTMLElement;
+
+ // Shift+Tab: If focus is on first element or the drawer panel itself, move to last
+ if ( event.shiftKey ) {
+ if (
+ activeElement === firstElement ||
+ activeElement === drawerElement
+ ) {
+ event.preventDefault();
+ lastElement?.focus();
+ }
+ } else if ( activeElement === lastElement ) {
+ // Tab: If focus is on last element, move to first
+ event.preventDefault();
+ firstElement?.focus();
+ }
+ }
+ };
+
+ if ( isOpen ) {
+ document.addEventListener( 'keydown', handleKeyDown );
+ }
+
+ return () => {
+ document.removeEventListener( 'keydown', handleKeyDown );
+ };
+ }, [ isOpen, onClose ] );
+
return (
<>
{ hasBackdrop && (
@@ -35,14 +124,21 @@ const FulfillmentDrawer: React.FC< Props > = ( {
onClick={ onClose }
role="presentation"
style={ { display: isOpen ? 'block' : 'none' } }
+ aria-hidden={ ! isOpen }
/>
) }
<div className="woocommerce-fulfillment-drawer">
<div
+ ref={ drawerRef }
className={ [
'woocommerce-fulfillment-drawer__panel',
isOpen ? 'is-open' : 'is-closed',
].join( ' ' ) }
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="fulfillment-drawer-header"
+ aria-hidden={ ! isOpen }
+ tabIndex={ -1 }
>
<ErrorBoundary>
<FulfillmentDrawerProvider orderId={ orderId }>
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/test/fulfillment-drawer-accessibility.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/test/fulfillment-drawer-accessibility.test.js
new file mode 100644
index 00000000000..c8256380323
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillment-drawer/test/fulfillment-drawer-accessibility.test.js
@@ -0,0 +1,255 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import '../../../../test-helper/global-mock';
+import FulfillmentDrawer from '../fulfillment-drawer';
+
+// Mock the drawer context and components that the drawer depends on
+jest.mock( '../../../../context/drawer-context', () => ( {
+ FulfillmentDrawerProvider: ( { children } ) => (
+ <div data-testid="drawer-provider">{ children }</div>
+ ),
+} ) );
+
+jest.mock( '../fulfillment-drawer-header', () => {
+ return function MockHeader( { onClose } ) {
+ return (
+ <div data-testid="drawer-header">
+ <h2 id="fulfillment-drawer-header">Test Header</h2>
+ <button
+ onClick={ onClose }
+ aria-label="Close fulfillment drawer"
+ >
+ ×
+ </button>
+ </div>
+ );
+ };
+} );
+
+jest.mock( '../fulfillment-drawer-body', () => {
+ return function MockBody( { children } ) {
+ return <div data-testid="drawer-body">{ children }</div>;
+ };
+} );
+
+jest.mock( '../../../fulfillments/new-fulfillment-form', () => {
+ return function MockForm() {
+ return (
+ <div data-testid="new-fulfillment-form">
+ <button data-testid="first-button">First</button>
+ <input data-testid="middle-input" type="text" />
+ <button data-testid="last-button">Last</button>
+ </div>
+ );
+ };
+} );
+
+jest.mock( '../../../fulfillments/fulfillments-list', () => {
+ return function MockList() {
+ return <div data-testid="fulfillments-list">List</div>;
+ };
+} );
+
+describe( 'FulfillmentDrawer Accessibility', () => {
+ const defaultProps = {
+ isOpen: true,
+ onClose: jest.fn(),
+ orderId: 123,
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+
+ // Mock requestAnimationFrame for focus management tests
+ let rafCounter = 0;
+ jest.spyOn( window, 'requestAnimationFrame' ).mockImplementation(
+ ( cb ) => {
+ const id = ++rafCounter;
+ cb( 0 );
+ return id;
+ }
+ );
+ jest.spyOn( window, 'cancelAnimationFrame' ).mockImplementation(
+ () => {}
+ );
+ } );
+
+ afterEach( () => {
+ window.requestAnimationFrame.mockRestore();
+ window.cancelAnimationFrame.mockRestore();
+ } );
+
+ it( 'should have proper ARIA attributes when open', () => {
+ render( <FulfillmentDrawer { ...defaultProps } /> );
+
+ const dialog = screen.getByRole( 'dialog' );
+ expect( dialog ).toHaveAttribute( 'aria-modal', 'true' );
+ expect( dialog ).toHaveAttribute(
+ 'aria-labelledby',
+ 'fulfillment-drawer-header'
+ );
+ expect( dialog ).not.toHaveAttribute( 'aria-label' );
+ expect( dialog ).toHaveAttribute( 'aria-hidden', 'false' );
+ } );
+
+ it( 'should be properly hidden when closed', () => {
+ render( <FulfillmentDrawer { ...defaultProps } isOpen={ false } /> );
+
+ const dialog = screen.getByRole( 'dialog', { hidden: true } );
+ expect( dialog ).toHaveAttribute( 'aria-hidden', 'true' );
+ } );
+
+ it( 'should close on Escape key press', () => {
+ const onClose = jest.fn();
+ render( <FulfillmentDrawer { ...defaultProps } onClose={ onClose } /> );
+
+ fireEvent.keyDown( document, { key: 'Escape' } );
+ expect( onClose ).toHaveBeenCalled();
+ } );
+
+ it( 'should have close button with proper aria-label', () => {
+ render( <FulfillmentDrawer { ...defaultProps } /> );
+
+ const closeButton = screen.getByLabelText( 'Close fulfillment drawer' );
+ expect( closeButton ).toBeInTheDocument();
+ } );
+
+ it( 'should have proper backdrop attributes', () => {
+ render(
+ <FulfillmentDrawer { ...defaultProps } hasBackdrop={ true } />
+ );
+
+ const backdrop = document.querySelector(
+ '.woocommerce-fulfillment-drawer__backdrop'
+ );
+ expect( backdrop ).toHaveAttribute( 'role', 'presentation' );
+ expect( backdrop ).toHaveAttribute( 'aria-hidden', 'false' );
+ } );
+
+ it( 'should allow background scrolling and clicking', () => {
+ const originalBodyOverflow = document.body.style.overflow;
+
+ // Render closed drawer
+ const { rerender } = render(
+ <FulfillmentDrawer { ...defaultProps } isOpen={ false } />
+ );
+
+ // Body should remain scrollable when drawer is closed
+ expect( document.body.style.overflow ).toBe( '' );
+
+ // Open the drawer
+ rerender( <FulfillmentDrawer { ...defaultProps } isOpen={ true } /> );
+
+ // Body should remain scrollable when drawer is open (allows background interaction)
+ expect( document.body.style.overflow ).toBe( '' );
+
+ // The drawer panel should be focusable and have proper attributes
+ const dialog = screen.getByRole( 'dialog' );
+ expect( dialog ).toHaveAttribute( 'tabindex', '-1' );
+ expect( dialog ).toHaveClass( 'woocommerce-fulfillment-drawer__panel' );
+
+ // Clean up
+ document.body.style.overflow = originalBodyOverflow;
+ } );
+
+ describe( 'Focus trapping', () => {
+ it( 'should wrap focus from last to first element on Tab', () => {
+ render( <FulfillmentDrawer { ...defaultProps } /> );
+
+ const lastButton = screen.getByTestId( 'last-button' );
+ lastButton.focus();
+
+ fireEvent.keyDown( document, { key: 'Tab' } );
+
+ // The close button is the first focusable element in the drawer
+ const closeButton = screen.getByLabelText(
+ 'Close fulfillment drawer'
+ );
+ expect( closeButton.ownerDocument.activeElement ).toBe(
+ closeButton
+ );
+ } );
+
+ it( 'should wrap focus from first to last element on Shift+Tab', () => {
+ render( <FulfillmentDrawer { ...defaultProps } /> );
+
+ const closeButton = screen.getByLabelText(
+ 'Close fulfillment drawer'
+ );
+ closeButton.focus();
+
+ fireEvent.keyDown( document, { key: 'Tab', shiftKey: true } );
+
+ const lastButton = screen.getByTestId( 'last-button' );
+ expect( lastButton.ownerDocument.activeElement ).toBe( lastButton );
+ } );
+
+ it( 'should wrap focus from drawer panel to last element on Shift+Tab', () => {
+ render( <FulfillmentDrawer { ...defaultProps } /> );
+
+ // The drawer panel itself gets focus when opened
+ const dialog = screen.getByRole( 'dialog' );
+ dialog.focus();
+
+ fireEvent.keyDown( document, { key: 'Tab', shiftKey: true } );
+
+ const lastButton = screen.getByTestId( 'last-button' );
+ expect( lastButton.ownerDocument.activeElement ).toBe( lastButton );
+ } );
+ } );
+
+ describe( 'Focus restoration', () => {
+ it( 'should restore focus to previously focused element when drawer closes', () => {
+ const triggerButton = document.createElement( 'button' );
+ triggerButton.textContent = 'Open Drawer';
+ document.body.appendChild( triggerButton );
+ triggerButton.focus();
+
+ const { rerender } = render(
+ <FulfillmentDrawer { ...defaultProps } isOpen={ true } />
+ );
+
+ // Close the drawer
+ rerender(
+ <FulfillmentDrawer { ...defaultProps } isOpen={ false } />
+ );
+
+ expect( triggerButton.ownerDocument.activeElement ).toBe(
+ triggerButton
+ );
+
+ // Clean up
+ document.body.removeChild( triggerButton );
+ } );
+
+ it( 'should not restore focus if previously focused element is disconnected', () => {
+ const triggerButton = document.createElement( 'button' );
+ triggerButton.textContent = 'Open Drawer';
+ document.body.appendChild( triggerButton );
+ triggerButton.focus();
+
+ const { rerender } = render(
+ <FulfillmentDrawer { ...defaultProps } isOpen={ true } />
+ );
+
+ // Remove the trigger button from DOM while drawer is open
+ document.body.removeChild( triggerButton );
+
+ // Close the drawer
+ rerender(
+ <FulfillmentDrawer { ...defaultProps } isOpen={ false } />
+ );
+
+ // Focus should NOT be on the disconnected element
+ expect( triggerButton.ownerDocument.activeElement ).not.toBe(
+ triggerButton
+ );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillments-card/card.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillments-card/card.tsx
index d6949698207..009acb9f5f5 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillments-card/card.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/user-interface/fulfillments-card/card.tsx
@@ -2,6 +2,7 @@
* External dependencies
*/
import { Button, Icon } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
import React, { ReactNode, useState } from 'react';
/**
@@ -36,6 +37,12 @@ export default function FulfillmentCard( {
__next40pxDefaultSize
size="small"
onClick={ () => setIsOpen( ! isOpen ) }
+ aria-label={
+ isOpen
+ ? __( 'Collapse section', 'woocommerce' )
+ : __( 'Expand section', 'woocommerce' )
+ }
+ aria-expanded={ isOpen }
>
<Icon
icon={
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
index e64af922362..c93df585dbd 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
@@ -40,9 +40,8 @@
align-items: center;
justify-content: center;
- &:focus {
- outline: none;
- box-shadow: none;
+ &:focus-visible {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 2px) var(--wp-admin-theme-color, #007cba);
}
}
}
@@ -173,6 +172,27 @@
line-height: 20px;
}
+ .woocommerce-fulfillment-item-expand-button {
+ width: 24px;
+ height: 24px;
+ border: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:focus-visible {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 2px) var(--wp-admin-theme-color, #007cba);
+ }
+
+ svg {
+ fill: #949494;
+ }
+
+ &:hover svg {
+ fill: #000;
+ }
+ }
+
.woocommerce-fulfillment-item-price {
min-width: 52px;
text-align: right;
@@ -274,9 +294,8 @@
align-items: center;
justify-content: center;
- &:focus {
- outline: none;
- box-shadow: none;
+ &:focus-visible {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus, 2px) var(--wp-admin-theme-color, #007cba);
}
}
}