diff --git a/package-lock.json b/package-lock.json index bd958b9e3c61b2..8178689aed9342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45236,6 +45236,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.32", + "@wordpress/a11y": "file:../a11y", "@wordpress/base-styles": "file:../base-styles", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", @@ -45971,6 +45972,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@react-spring/web": "^9.4.5", + "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", @@ -46533,6 +46535,7 @@ "version": "0.13.0", "license": "GPL-2.0-or-later", "dependencies": { + "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/components": "file:../components", diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index fce0478c67dbf2..f3cbb2c5444d04 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -3278,7 +3278,7 @@ describe( 'URL validation', () => { ] )( 'should prevent submission for $description', async ( { inputUrl } ) => { - render( + const { container } = render( { // Wait for validation error to appear await waitFor( () => { expect( - screen.getByText( 'Please enter a valid URL.' ) + within( container ).getByText( 'Please enter a valid URL.' ) ).toBeInTheDocument(); } ); @@ -3407,7 +3407,7 @@ describe( 'URL validation', () => { it( 'should show validation error when clicking Apply button with invalid URL', async () => { // When editing an existing link, use Apply button const existingLink = { url: 'https://example.com', title: 'Example' }; - render( + const { container } = render( { await waitFor( () => { expect( - screen.getByText( 'Please enter a valid URL.' ) + within( container ).getByText( 'Please enter a valid URL.' ) ).toBeVisible(); }, { timeout: 100 } @@ -3441,7 +3441,7 @@ describe( 'URL validation', () => { it( 'should show validation error when pressing Enter to submit with an invalid URL', async () => { // When editing an existing link, use Apply button const existingLink = { url: 'https://example.com', title: 'Example' }; - render( + const { container } = render( { await waitFor( () => { expect( - screen.getByText( 'Please enter a valid URL.' ) + within( container ).getByText( 'Please enter a valid URL.' ) ).toBeVisible(); }, { timeout: 100 } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index dde862d420502d..8ea0f7e46e06d2 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- `ControlWithError`: Remove redundant inline aria-live region for validation error messages to prevent duplicate announcements. ([#79600](https://github.com/WordPress/gutenberg/pull/79600)). + ### Breaking Changes - `ExternalLink`: No longer sets the `rel` attribute by default. Consumers relying on the previous behavior should pass `rel` explicitly ([#79743](https://github.com/WordPress/gutenberg/pull/79743)). diff --git a/packages/components/src/validated-form-controls/control-with-error.tsx b/packages/components/src/validated-form-controls/control-with-error.tsx index ef3f985bd33c30..0889acf1e418b5 100644 --- a/packages/components/src/validated-form-controls/control-with-error.tsx +++ b/packages/components/src/validated-form-controls/control-with-error.tsx @@ -310,7 +310,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >( ), required, } ) } -
{ visibleMessage }
+ { visibleMessage } ); } diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 7f6331fa7410c3..543f382c1a3426 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -14,6 +14,8 @@ - Widen React peer dependency ranges to `^18 || ^19` to support both React 18 and React 19 environments ([#80024](https://github.com/WordPress/gutenberg/pull/80024)). +- `ValidatedDateControl`: Update validation error message to be announced by screen readers using `speak` instead of an inline `aria-live` region ([#79600](https://github.com/WordPress/gutenberg/pull/79600)) + ## 17.1.0 (2026-07-01) ## 17.0.0 (2026-06-24) diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 89306704c02d43..d1adcfce0409f3 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -52,6 +52,7 @@ "sideEffects": false, "dependencies": { "@ariakit/react": "^0.4.32", + "@wordpress/a11y": "file:../a11y", "@wordpress/base-styles": "file:../base-styles", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", diff --git a/packages/dataviews/src/components/dataform-controls/date.tsx b/packages/dataviews/src/components/dataform-controls/date.tsx index bc6cba1e5d9a03..5280d27f99c61c 100644 --- a/packages/dataviews/src/components/dataform-controls/date.tsx +++ b/packages/dataviews/src/components/dataform-controls/date.tsx @@ -22,6 +22,7 @@ import { privateApis as componentsPrivateApis, __experimentalInputControl as InputControl, } from '@wordpress/components'; +import { speak } from '@wordpress/a11y'; import { useCallback, useEffect, @@ -241,6 +242,12 @@ function ValidatedDateControl< Item >( { } }, [ isTouched, isValid, validity, validateRefs ] ); + useEffect( () => { + if ( isTouched && customValidity?.message ) { + speak( customValidity.message ); + } + }, [ isTouched, customValidity?.message ] ); + const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { if ( isTouched ) { return; @@ -259,26 +266,24 @@ function ValidatedDateControl< Item >( { return (
{ children } -
- { customValidity && ( -

- - { customValidity.message } -

- ) } -
+ { customValidity && ( +

+ + { customValidity.message } +

+ ) }
); } diff --git a/packages/dataviews/tsconfig.json b/packages/dataviews/tsconfig.json index 41f015ba956477..2717e836d2f201 100644 --- a/packages/dataviews/tsconfig.json +++ b/packages/dataviews/tsconfig.json @@ -11,6 +11,7 @@ ] }, "references": [ + { "path": "../a11y" }, { "path": "../components" }, { "path": "../compose" }, { "path": "../data" }, diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md index af678cada75fc4..85608489cd3bbe 100644 --- a/packages/fields/CHANGELOG.md +++ b/packages/fields/CHANGELOG.md @@ -6,6 +6,8 @@ - Widen React peer dependency ranges to `^18 || ^19` to support both React 18 and React 19 environments ([#80024](https://github.com/WordPress/gutenberg/pull/80024)). +- `MediaEdit`: Update validation error message to be announced by screen readers. ([#79600](https://github.com/WordPress/gutenberg/pull/79600)) + ## 0.42.0 (2026-07-01) ## 0.41.0 (2026-06-24) diff --git a/packages/fields/package.json b/packages/fields/package.json index a04e2378c917e8..9c00a51e298bf2 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -47,6 +47,7 @@ ], "dependencies": { "@react-spring/web": "^9.4.5", + "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", diff --git a/packages/fields/src/components/media-edit/index.tsx b/packages/fields/src/components/media-edit/index.tsx index 22df1c56177664..3308367361ad1c 100644 --- a/packages/fields/src/components/media-edit/index.tsx +++ b/packages/fields/src/components/media-edit/index.tsx @@ -41,6 +41,7 @@ import { chevronRight, } from '@wordpress/icons'; import { VisuallyHidden, Tooltip } from '@wordpress/ui'; +import { speak } from '@wordpress/a11y'; import { MediaUpload, uploadMedia, @@ -838,6 +839,13 @@ export default function MediaEdit< Item >( { setCustomValidity( undefined ); } }, [ isTouched, field.isValid, validity ] ); + + useEffect( () => { + if ( isTouched && customValidity?.message ) { + speak( customValidity.message ); + } + }, [ isTouched, customValidity?.message ] ); + const onBlur = useCallback( ( event: React.FocusEvent< HTMLElement > ) => { if ( isTouched ) { @@ -961,25 +969,23 @@ export default function MediaEdit< Item >( { /> { customValidity && ( -
-

- - { customValidity.message } -

-
+

+ + { customValidity.message } +

) } ); diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json index 9cbb63a0bd84dd..59bc6e4b65d251 100644 --- a/packages/fields/tsconfig.json +++ b/packages/fields/tsconfig.json @@ -5,6 +5,7 @@ "checkJs": false }, "references": [ + { "path": "../a11y" }, { "path": "../api-fetch" }, { "path": "../blob" }, { "path": "../components" }, diff --git a/packages/media-editor/CHANGELOG.md b/packages/media-editor/CHANGELOG.md index 36210addf63b85..105e9f600d9e09 100644 --- a/packages/media-editor/CHANGELOG.md +++ b/packages/media-editor/CHANGELOG.md @@ -6,6 +6,8 @@ - Widen React peer dependency ranges to `^18 || ^19` to support both React 18 and React 19 environments ([#80024](https://github.com/WordPress/gutenberg/pull/80024)). +- `useAriaAnnouncer`: Update to use `speak` for screen reader announcements instead of an inline `aria-live` region. ([#79600](https://github.com/WordPress/gutenberg/pull/79600)) + ## 0.13.0 (2026-07-01) ## 0.12.0 (2026-06-24) diff --git a/packages/media-editor/package.json b/packages/media-editor/package.json index 4aee2bd5541f87..f6fc1122841680 100644 --- a/packages/media-editor/package.json +++ b/packages/media-editor/package.json @@ -43,6 +43,7 @@ "build-module/store/index.mjs" ], "dependencies": { + "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/components": "file:../components", diff --git a/packages/media-editor/src/image-editor/react/components/cropper.tsx b/packages/media-editor/src/image-editor/react/components/cropper.tsx index 94154ca7dffc66..3f58f1e7b0af04 100644 --- a/packages/media-editor/src/image-editor/react/components/cropper.tsx +++ b/packages/media-editor/src/image-editor/react/components/cropper.tsx @@ -276,8 +276,9 @@ function CropperInner( onStateChange?.( state ); }, [ state, onStateChange ] ); - // ARIA live region: announce significant state changes for screen readers. - const ariaMessage = useAriaAnnouncer( state ); + // ARIA announcements: announce significant state changes for screen readers + // via the centralized @wordpress/a11y speak() API called inside the hook. + useAriaAnnouncer( state ); // Compute fitted image dimensions and visual bounds from camera math. const naturalWidth = state.image?.naturalWidth ?? 0; @@ -1107,16 +1108,6 @@ function CropperInner( /> ) } - - { /* ARIA live region for screen reader announcements */ } -
- { ariaMessage } -
); diff --git a/packages/media-editor/src/image-editor/react/hooks/test/use-aria-announcer.ts b/packages/media-editor/src/image-editor/react/hooks/test/use-aria-announcer.ts index 5eb7936c3fc164..8be411443a669d 100644 --- a/packages/media-editor/src/image-editor/react/hooks/test/use-aria-announcer.ts +++ b/packages/media-editor/src/image-editor/react/hooks/test/use-aria-announcer.ts @@ -3,6 +3,11 @@ */ import { act, renderHook } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; + /** * Internal dependencies */ @@ -10,6 +15,10 @@ import { useAriaAnnouncer } from '../use-aria-announcer'; import { DEFAULT_STATE } from '../../../core/constants'; import type { CropperState } from '../../../core/types'; +jest.mock( '@wordpress/a11y', () => ( { + speak: jest.fn(), +} ) ); + function makeState( overrides: Partial< CropperState > = {} ): CropperState { return { ...DEFAULT_STATE, @@ -28,6 +37,7 @@ function makeState( overrides: Partial< CropperState > = {} ): CropperState { describe( 'useAriaAnnouncer', () => { beforeEach( () => { jest.useFakeTimers(); + ( speak as jest.Mock ).mockClear(); } ); afterEach( () => { @@ -35,7 +45,7 @@ describe( 'useAriaAnnouncer', () => { } ); it( 'announces horizontal flip changes', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -49,7 +59,7 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Flipped horizontally' ); + expect( speak ).toHaveBeenCalledWith( 'Flipped horizontally' ); rerender( { state: makeState( { @@ -58,11 +68,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Flip removed' ); + expect( speak ).toHaveBeenCalledWith( 'Flip removed' ); } ); it( 'announces combined flip state when both axes are active', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -76,7 +86,9 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Flipped horizontally and vertically' ); + expect( speak ).toHaveBeenCalledWith( + 'Flipped horizontally and vertically' + ); } ); it( 'announces crop in pixels when image dimensions are available', () => { @@ -85,7 +97,7 @@ describe( 'useAriaAnnouncer', () => { naturalWidth: 1000, naturalHeight: 800, }; - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState( { image } ) } } ); @@ -100,7 +112,7 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Crop 800 by 400 pixels' ); + expect( speak ).toHaveBeenCalledWith( 'Crop 800 by 400 pixels' ); } ); it( 'announces a single-pixel crop change', () => { @@ -109,7 +121,7 @@ describe( 'useAriaAnnouncer', () => { naturalWidth: 2000, naturalHeight: 1000, }; - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -133,7 +145,7 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Crop 1001 by 500 pixels' ); + expect( speak ).toHaveBeenCalledWith( 'Crop 1001 by 500 pixels' ); } ); it( 'does not announce the crop when only zoom changes', () => { @@ -142,7 +154,7 @@ describe( 'useAriaAnnouncer', () => { naturalWidth: 2000, naturalHeight: 1000, }; - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -165,7 +177,7 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Zoom 150%' ); + expect( speak ).toHaveBeenCalledWith( 'Zoom 150%' ); } ); it( 'announces a single-pixel crop change on a rotated non-square image', () => { @@ -177,7 +189,7 @@ describe( 'useAriaAnnouncer', () => { naturalWidth: 800, naturalHeight: 2000, }; - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -201,11 +213,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Crop 1001 by 400 pixels' ); + expect( speak ).toHaveBeenCalledWith( 'Crop 1001 by 400 pixels' ); } ); it( 'announces clockwise rotation with direction', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -217,11 +229,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 15 degrees clockwise' ); + expect( speak ).toHaveBeenCalledWith( 'Rotated 15 degrees clockwise' ); } ); it( 'announces counterclockwise when flip inverts visual direction', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -243,11 +255,13 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 10 degrees counterclockwise' ); + expect( speak ).toHaveBeenCalledWith( + 'Rotated 10 degrees counterclockwise' + ); } ); it( 'announces a 90° snap under a single flip in visual terms', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -271,11 +285,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 90 degrees clockwise' ); + expect( speak ).toHaveBeenCalledWith( 'Rotated 90 degrees clockwise' ); } ); it( 'announces a 90° snap under a vertical flip in visual terms', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -296,13 +310,13 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 90 degrees clockwise' ); + expect( speak ).toHaveBeenCalledWith( 'Rotated 90 degrees clockwise' ); } ); it( 'announces non-inverted rotation when both axes are flipped', () => { // Two mirrors restore handedness (equivalent to a 180° rotation), so // the visual direction matches the unflipped case, not the single-flip. - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -323,11 +337,13 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 90 degrees counterclockwise' ); + expect( speak ).toHaveBeenCalledWith( + 'Rotated 90 degrees counterclockwise' + ); } ); it( 'announces multiple changes with rotation first', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -339,13 +355,13 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( + expect( speak ).toHaveBeenCalledWith( 'Rotated 90 degrees clockwise, Zoom 150%' ); } ); it( 'announces rotation back to zero', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState( { rotation: 15 } ) } } ); @@ -357,11 +373,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotation 0 degrees' ); + expect( speak ).toHaveBeenCalledWith( 'Rotation 0 degrees' ); } ); it( 'announces rotation after 90° snap plus fine adjustment', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -374,11 +390,11 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 105 degrees clockwise' ); + expect( speak ).toHaveBeenCalledWith( 'Rotated 105 degrees clockwise' ); } ); it( 'announces CCW for 90° snap CCW plus fine CCW adjustment', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -392,11 +408,13 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 105 degrees counterclockwise' ); + expect( speak ).toHaveBeenCalledWith( + 'Rotated 105 degrees counterclockwise' + ); } ); it( 'announces CCW fine rotation from 0° (stored as 350°)', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -410,7 +428,9 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Rotated 10 degrees counterclockwise' ); + expect( speak ).toHaveBeenCalledWith( + 'Rotated 10 degrees counterclockwise' + ); } ); it( 'suppresses unchanged values in combined announcements', () => { @@ -419,7 +439,7 @@ describe( 'useAriaAnnouncer', () => { naturalWidth: 1000, naturalHeight: 800, }; - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { @@ -430,6 +450,8 @@ describe( 'useAriaAnnouncer', () => { act( () => jest.advanceTimersByTime( 300 ) ); + ( speak as jest.Mock ).mockClear(); + // Only change crop — zoom and rotation should be suppressed. rerender( { state: makeState( { @@ -440,13 +462,17 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Crop 500 by 400 pixels' ); - expect( result.current ).not.toContain( 'Zoom' ); - expect( result.current ).not.toContain( 'Rotated' ); + expect( speak ).toHaveBeenCalledWith( 'Crop 500 by 400 pixels' ); + expect( speak ).not.toHaveBeenCalledWith( + expect.stringContaining( 'Zoom' ) + ); + expect( speak ).not.toHaveBeenCalledWith( + expect.stringContaining( 'Rotated' ) + ); } ); it( 'announces only zoom when only zoom changes', () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( ( { state } ) => useAriaAnnouncer( state ), { initialProps: { state: makeState() } } ); @@ -458,6 +484,6 @@ describe( 'useAriaAnnouncer', () => { } ); act( () => jest.advanceTimersByTime( 300 ) ); - expect( result.current ).toBe( 'Zoom 150%' ); + expect( speak ).toHaveBeenCalledWith( 'Zoom 150%' ); } ); } ); diff --git a/packages/media-editor/src/image-editor/react/hooks/use-aria-announcer.ts b/packages/media-editor/src/image-editor/react/hooks/use-aria-announcer.ts index 646f53854eb72b..767e0c2ddce779 100644 --- a/packages/media-editor/src/image-editor/react/hooks/use-aria-announcer.ts +++ b/packages/media-editor/src/image-editor/react/hooks/use-aria-announcer.ts @@ -1,12 +1,8 @@ /** * WordPress dependencies */ -import { - useState, - useEffect, - useLayoutEffect, - useRef, -} from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; +import { useEffect, useLayoutEffect, useRef } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; /** @@ -158,16 +154,16 @@ function buildAnnouncement( /** * Debounce and dedupe ARIA-live announcements for screen readers as the - * cropper state changes. Returns the current announcement message to render - * inside an `aria-live="polite"` region. + * cropper state changes. Calls `speak()` from `@wordpress/a11y` to use the + * centralized, persistent live region instead of a local one that may be + * dynamically mounted. * - * Debouncing avoids flooding the live region during drag/pointermove bursts; + * Debouncing avoids flooding announcements during drag/pointermove bursts; * deduping avoids re-announcing the same state. * * @param state The current cropper state to announce. */ -export function useAriaAnnouncer( state: CropperState ): string { - const [ ariaMessage, setAriaMessage ] = useState( '' ); +export function useAriaAnnouncer( state: CropperState ): void { const timerRef = useRef< ReturnType< typeof setTimeout > >(); const prevMessageRef = useRef( '' ); const prevStateRef = useRef< CropperState | null >( null ); @@ -184,7 +180,7 @@ export function useAriaAnnouncer( state: CropperState ): string { prevStateRef.current = current; if ( msg !== prevMessageRef.current ) { prevMessageRef.current = msg; - setAriaMessage( msg ); + speak( msg ); } }, ARIA_DEBOUNCE_MS ); @@ -199,6 +195,4 @@ export function useAriaAnnouncer( state: CropperState ): string { state.flip.horizontal, state.flip.vertical, ] ); - - return ariaMessage; } diff --git a/packages/media-editor/tsconfig.json b/packages/media-editor/tsconfig.json index ef356b1d0abdd2..8615c616214eb4 100644 --- a/packages/media-editor/tsconfig.json +++ b/packages/media-editor/tsconfig.json @@ -6,6 +6,7 @@ "checkJs": false }, "references": [ + { "path": "../a11y" }, { "path": "../api-fetch" }, { "path": "../components" }, { "path": "../compose" }, diff --git a/test/e2e/specs/editor/blocks/navigation.spec.js b/test/e2e/specs/editor/blocks/navigation.spec.js index 5472cc26f7f107..e189c0391854c1 100644 --- a/test/e2e/specs/editor/blocks/navigation.spec.js +++ b/test/e2e/specs/editor/blocks/navigation.spec.js @@ -1863,7 +1863,7 @@ test.describe( 'Navigation block', () => { // Verify validation error is shown await expect( - page.getByText( 'Please enter a valid URL.' ) + linkPopover.getByText( 'Please enter a valid URL.' ) ).toBeVisible(); } ); @@ -1884,7 +1884,7 @@ test.describe( 'Navigation block', () => { // Verify validation error is gone now await expect( - page.getByText( 'Please enter a valid URL.' ) + linkPopover.getByText( 'Please enter a valid URL.' ) ).toBeHidden(); await expect( linkInput ).toHaveValue(