diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4b1cf06333cc..4287d9333557 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6369,6 +6369,9 @@ importers:
'@tanstack/react-query':
specifier: 5.102.8
version: 5.102.8(react@18.3.1)
+ '@wordpress/a11y':
+ specifier: 4.54.0
+ version: 4.54.0
'@wordpress/api-fetch':
specifier: 7.54.0
version: 7.54.0
diff --git a/projects/plugins/protect/changelog/update-protect-notice-announce-errors b/projects/plugins/protect/changelog/update-protect-notice-announce-errors
new file mode 100644
index 000000000000..78cca1c3f8bf
--- /dev/null
+++ b/projects/plugins/protect/changelog/update-protect-notice-announce-errors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Notices: Announce errors to screen readers.
diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json
index eeb347ee062d..9630f4bfd7b7 100644
--- a/projects/plugins/protect/package.json
+++ b/projects/plugins/protect/package.json
@@ -32,6 +32,7 @@
"@automattic/jetpack-connection": "workspace:*",
"@automattic/jetpack-scan": "workspace:*",
"@tanstack/react-query": "5.102.8",
+ "@wordpress/a11y": "4.54.0",
"@wordpress/api-fetch": "7.54.0",
"@wordpress/components": "40.0.0",
"@wordpress/compose": "8.7.0",
diff --git a/projects/plugins/protect/src/js/components/notice/README.md b/projects/plugins/protect/src/js/components/notice/README.md
index 60e3f8582494..e44cf6cc881c 100644
--- a/projects/plugins/protect/src/js/components/notice/README.md
+++ b/projects/plugins/protect/src/js/components/notice/README.md
@@ -5,12 +5,11 @@ A simple notice component for displaying alerts and messages to the user.
## Usage
```jsx
-
+
```
## Props
-Supported `type` values are `info`, `success`, `warning`, and `error`.
\ No newline at end of file
+Supported `type` values are `info`, `success`, `warning`, and `error`.
+
+`spokenMessage` is the plain-text announcement for screen readers, used when `message` carries markup. Only `floating` notices are announced. `noticeId` changes on every notice, so an identical repeat announces again.
diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx
index 0e60c7e57330..cedf877b1f0c 100644
--- a/projects/plugins/protect/src/js/components/notice/index.jsx
+++ b/projects/plugins/protect/src/js/components/notice/index.jsx
@@ -1,3 +1,4 @@
+import { speak } from '@wordpress/a11y';
import { __ } from '@wordpress/i18n';
import { Notice as WPNotice } from '@wordpress/ui';
import clsx from 'clsx';
@@ -17,6 +18,8 @@ const Notice = ( {
duration = null,
floating = false,
message,
+ noticeId = 0,
+ spokenMessage,
type = 'success',
} ) => {
const { clearNotice } = useNotices();
@@ -25,6 +28,16 @@ const Notice = ( {
clearNotice();
}, [ clearNotice ] );
+ const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null );
+
+ // Keyed on `noticeId`: effect deps compare by value, so an identical repeat would not re-fire.
+ useEffect( () => {
+ // Toast only: notices rendered inside a modal are not announced today.
+ if ( floating && 'string' === typeof spoken ) {
+ speak( spoken, 'error' === type ? 'assertive' : 'polite' );
+ }
+ }, [ noticeId, floating, spoken, type ] );
+
/**
* Clears the notice automatically after {duration} milliseconds.
*/
@@ -36,7 +49,7 @@ const Notice = ( {
}
return () => clearTimeout( timeout );
- }, [ clearNotice, duration, message ] );
+ }, [ clearNotice, duration, noticeId ] );
return (
{ message }
{ dismissable && (
diff --git a/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts
index f737355afe62..784b7d5bfeb1 100644
--- a/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts
+++ b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts
@@ -144,14 +144,15 @@ export default function useFixersQuery( {
enabled: isRegistered,
} );
- // Handle error if present in the query result
+ // Once per error streak, not per failed poll: each failure is a new Error object, and
+ // polling one every 5s would interrupt the screen reader with the same message each time.
useEffect( () => {
- if ( fixersQuery.isError && fixersQuery.error ) {
+ if ( fixersQuery.isError ) {
// Reset the query data to the initial state
queryClient.setQueryData( [ QUERY_FIXERS_KEY ], initialData );
showErrorNotice( __( 'An error occurred while fetching fixers status.', 'jetpack-protect' ) );
}
- }, [ fixersQuery.isError, fixersQuery.error, queryClient, showErrorNotice ] );
+ }, [ fixersQuery.isError, queryClient, showErrorNotice ] );
return fixersQuery;
}
diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx
index e37304239106..dba65efe55c5 100644
--- a/projects/plugins/protect/src/js/hooks/use-notices.tsx
+++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx
@@ -8,6 +8,9 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react';
interface NoticeState {
message?: string | JSX.Element;
+ spokenMessage?: string;
+ // Stamped by the provider on every notice, so an identical repeat still announces.
+ noticeId?: number;
dismissable?: boolean;
duration?: number;
type?: 'success' | 'info' | 'warning' | 'error';
@@ -18,10 +21,21 @@ interface NoticeContextValue {
setNotice: Dispatch< SetStateAction< NoticeState > >;
}
+let lastNoticeId = 0;
+
const NoticeContext = createContext< NoticeContextValue | undefined >( undefined );
export const NoticeProvider: FC< { children: ReactNode } > = ( { children } ) => {
- const [ notice, setNotice ] = useState< NoticeState >( null );
+ const [ notice, setNoticeState ] = useState< NoticeState >( null );
+
+ const setNotice: Dispatch< SetStateAction< NoticeState > > = useCallback( value => {
+ const noticeId = ++lastNoticeId;
+
+ setNoticeState( previous => {
+ const next = 'function' === typeof value ? value( previous ) : value;
+ return next ? { ...next, noticeId } : next;
+ } );
+ }, [] );
return (
{ children }
@@ -66,27 +80,30 @@ export default function useNotices() {
const showErrorNotice = useCallback(
( message: string ) => {
+ const error = message || __( 'An error occurred.', 'jetpack-protect' );
+ const advice = __(
+ 'Please try again or contact support.',
+ 'jetpack-protect'
+ );
+
setNotice( {
type: 'error',
dismissable: true,
+ // The same translated string, minus the one interpolation tag it carries.
+ // Strip it by name: a generic tag strip trips CodeQL's sanitization rule.
+ spokenMessage: `${ error } ${ advice.replace( /<\/?supportLink>/g, '' ) }`,
message: (
<>
- { message || __( 'An error occurred.', 'jetpack-protect' ) }{ ' ' }
- { createInterpolateElement(
- __(
- 'Please try again or contact support.',
- 'jetpack-protect'
+ { error }{ ' ' }
+ { createInterpolateElement( advice, {
+ supportLink: (
+
),
- {
- supportLink: (
-
- ),
- }
- ) }
+ } ) }
>
),
} );