Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5c62af7
Protect notices: back the notice component with the @wordpress/ui Notice
CGastrell Sep 9, 2026
5582d04
Protect notices: keep the notice silent and its dismiss label
CGastrell Sep 9, 2026
a1778e2
Protect notices: let the toast announce, and follow the three review …
CGastrell Sep 9, 2026
3cbca29
Protect notices: never hand a JSX message to the announcer
CGastrell Sep 9, 2026
b7982ff
Protect notices: bundle the two packages the close control pulls in
CGastrell Sep 9, 2026
d1f5027
Protect notices: correct a comment the e2e scoping outdated
CGastrell Sep 9, 2026
f1c6b65
Protect notices: announce error notices to screen readers
CGastrell Sep 9, 2026
3957b87
Protect notices: speak the whole error, and speak it every time
CGastrell Sep 9, 2026
d7fbcf2
Protect notices: re-announce without stealing focus
CGastrell Sep 9, 2026
f5cbff9
Protect notices: announce per notice, not per distinct message
CGastrell Sep 10, 2026
7f85c8b
Protect notices: tie the dismiss timer to the notice as well
CGastrell Sep 10, 2026
43abf94
Protect notices: drop a comment's claim of a nonexistent dialog
CGastrell Sep 10, 2026
0a5adf3
Merge branch 'update/protect-notice-wpds' into update/protect-notice-…
CGastrell Sep 10, 2026
8e1dc64
Protect notices: keep the joint-bundling constraint on the requestMap
CGastrell Sep 10, 2026
c5170eb
Merge branch 'update/protect-notice-wpds' into update/protect-notice-…
CGastrell Sep 10, 2026
b053998
Protect notices: point at the requestMap rationale instead of restati…
CGastrell Sep 10, 2026
975d7fe
Merge branch 'update/protect-notice-wpds' into update/protect-notice-…
CGastrell Sep 10, 2026
3fec77d
Merge remote-tracking branch 'origin/trunk' into update/protect-notic…
CGastrell Sep 10, 2026
b24acf2
Protect notices: name the tag the spoken message strips
CGastrell Sep 10, 2026
62214f7
Protect notices: answer the review on the announcement
dhasilva Sep 11, 2026
cdbb966
Protect notices: announce a failing fixers poll once per streak
dhasilva Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Notices: Announce errors to screen readers.
1 change: 1 addition & 0 deletions projects/plugins/protect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 4 additions & 5 deletions projects/plugins/protect/src/js/components/notice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ A simple notice component for displaying alerts and messages to the user.
## Usage

```jsx
<Notice
type="info"
message="Code is poetry."
/>
<Notice type="info" message="Code is poetry." />
```

## Props

Supported `type` values are `info`, `success`, `warning`, and `error`.
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.
21 changes: 17 additions & 4 deletions projects/plugins/protect/src/js/components/notice/index.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { speak } from '@wordpress/a11y';
import { __ } from '@wordpress/i18n';
import { Notice as WPNotice } from '@wordpress/ui';
import clsx from 'clsx';
Expand All @@ -17,6 +18,8 @@ const Notice = ( {
duration = null,
floating = false,
message,
noticeId = 0,
spokenMessage,
type = 'success',
} ) => {
const { clearNotice } = useNotices();
Expand All @@ -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.
*/
Expand All @@ -36,7 +49,7 @@ const Notice = ( {
}

return () => clearTimeout( timeout );
}, [ clearNotice, duration, message ] );
}, [ clearNotice, duration, noticeId ] );

return (
<WPNotice.Root
Expand All @@ -46,9 +59,9 @@ const Notice = ( {
styles[ `notice--${ type }` ],
floating && styles[ 'notice--floating' ]
) }
// `Notice.Root` serializes `spokenMessage` mid-render, so a JSX message runs its
// hooks inside Root and corrupts hook order. Strings only, and only for the toast.
spokenMessage={ floating && 'string' === typeof message ? message : null }
// Null, not omitted: the default is the children, which `Notice.Root` serializes
// mid-render, corrupting hook order. Drop with the prop (WordPress/gutenberg#82737).
spokenMessage={ null }
>
<WPNotice.Description>{ message }</WPNotice.Description>
{ dismissable && (
Expand Down
7 changes: 4 additions & 3 deletions projects/plugins/protect/src/js/data/scan/use-fixers-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
49 changes: 33 additions & 16 deletions projects/plugins/protect/src/js/hooks/use-notices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<NoticeContext.Provider value={ { notice, setNotice } }>{ children }</NoticeContext.Provider>
Expand Down Expand Up @@ -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 <supportLink>contact support</supportLink>.',
'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 <supportLink>contact support</supportLink>.',
'jetpack-protect'
{ error }{ ' ' }
{ createInterpolateElement( advice, {
supportLink: (
<Link
openInNewTab
href={ hasPlan ? PAID_PLUGIN_SUPPORT_URL : FREE_PLUGIN_SUPPORT_URL }
children={ null }
/>
),
{
supportLink: (
<Link
openInNewTab
href={ hasPlan ? PAID_PLUGIN_SUPPORT_URL : FREE_PLUGIN_SUPPORT_URL }
children={ null }
/>
),
}
) }
} ) }
</>
),
} );
Expand Down
Loading