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: ( - - ), - } - ) } + } ) } ), } );