diff --git a/changelog.txt b/changelog.txt
index 16f8d17800a051..1c6f00ec27fa53 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -449,6 +449,8 @@ The following contributors merged PRs in this release:
#### Block Library
- Lodash: Remove `_.pickBy()` from latest posts block. ([46974](https://github.com/WordPress/gutenberg/pull/46974))
+### Accessibility
+- Block Editor: Revert `aria-controls` to `aria-owns` in `URLInput` to use the more broadly supported ARIA 1.0 combobox pattern. ([47148](https://github.com/WordPress/gutenberg/pull/47148))
### Experiments
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 bf48d93305fca6..3133f8e075d4f0 100644
--- a/packages/block-editor/src/components/link-control/test/index.js
+++ b/packages/block-editor/src/components/link-control/test/index.js
@@ -137,7 +137,122 @@ describe( 'Basic rendering', () => {
// Search Input UI.
const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
- expect( searchInput ).toBeInTheDocument();
+ expect( searchInput ).toBeVisible();
+ } );
+
+ it( 'should have aria-owns attribute to follow the ARIA 1.0 pattern', () => {
+ render( );
+
+ // Search Input UI.
+ const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
+
+ expect( searchInput ).toBeVisible();
+ // Make sure we use the ARIA 1.0 pattern with aria-owns.
+ // See https://github.com/WordPress/gutenberg/issues/47147
+ expect( searchInput ).not.toHaveAttribute( 'aria-controls' );
+ expect( searchInput ).toHaveAttribute( 'aria-owns' );
+ } );
+
+ it( 'should have aria-selected attribute only on the highlighted item', async () => {
+ const user = userEvent.setup();
+
+ let resolver;
+ mockFetchSearchSuggestions.mockImplementation(
+ () =>
+ new Promise( ( resolve ) => {
+ resolver = resolve;
+ } )
+ );
+
+ render( );
+
+ // Search Input UI.
+ const searchInput = screen.getByRole( 'combobox', { name: 'URL' } );
+
+ // Simulate searching for a term.
+ await user.type( searchInput, 'Hello' );
+
+ // Wait for the spinner SVG icon to be rendered.
+ expect( await screen.findByRole( 'presentation' ) ).toBeVisible();
+ // Check the suggestions list is not rendered yet.
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+
+ // Make the search suggestions fetch return a response.
+ resolver( fauxEntitySuggestions );
+
+ const resultsList = await screen.findByRole( 'listbox', {
+ name: 'Search results for "Hello"',
+ } );
+
+ // Check the suggestions list is rendered.
+ expect( resultsList ).toBeVisible();
+ // Check the spinner SVG icon is not rendered any longer.
+ expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument();
+
+ const searchResultElements =
+ within( resultsList ).getAllByRole( 'option' );
+
+ expect( searchResultElements ).toHaveLength(
+ // The fauxEntitySuggestions length plus the 'Press ENTER to add this link' button.
+ fauxEntitySuggestions.length + 1
+ );
+
+ // Step down into the search results, highlighting the first result item.
+ triggerArrowDown( searchInput );
+
+ const firstSearchSuggestion = searchResultElements[ 0 ];
+ const secondSearchSuggestion = searchResultElements[ 1 ];
+
+ let selectedSearchResultElement = screen.getByRole( 'option', {
+ selected: true,
+ } );
+
+ // We should have highlighted the first item using the keyboard.
+ expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
+
+ // Check the aria-selected attribute is set only on the highlighted item.
+ expect( firstSearchSuggestion ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ // Check the aria-selected attribute is omitted on the non-highlighted items.
+ expect( secondSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
+
+ // Step down into the search results, highlighting the second result item.
+ triggerArrowDown( searchInput );
+
+ selectedSearchResultElement = screen.getByRole( 'option', {
+ selected: true,
+ } );
+
+ // We should have highlighted the first item using the keyboard.
+ expect( selectedSearchResultElement ).toEqual( secondSearchSuggestion );
+
+ // Check the aria-selected attribute is omitted on non-highlighted items.
+ expect( firstSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
+ // Check the aria-selected attribute is set only on the highlighted item.
+ expect( secondSearchSuggestion ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+
+ // Step up into the search results, highlighting the first result item.
+ triggerArrowUp( searchInput );
+
+ selectedSearchResultElement = screen.getByRole( 'option', {
+ selected: true,
+ } );
+
+ // We should be back to highlighting the first search result again.
+ expect( selectedSearchResultElement ).toEqual( firstSearchSuggestion );
+
+ // Check the aria-selected attribute is set only on the highlighted item.
+ expect( firstSearchSuggestion ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ // Check the aria-selected attribute is omitted on non-highlighted items.
+ expect( secondSearchSuggestion ).not.toHaveAttribute( 'aria-selected' );
} );
it( 'should not render protocol in links', async () => {
@@ -559,7 +674,7 @@ describe( 'Manual link entry', () => {
} );
// Verify the UI hasn't allowed submission.
- expect( searchInput ).toBeInTheDocument();
+ expect( searchInput ).toBeVisible();
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
}
@@ -601,7 +716,7 @@ describe( 'Manual link entry', () => {
} );
// Verify the UI hasn't allowed submission.
- expect( searchInput ).toBeInTheDocument();
+ expect( searchInput ).toBeVisible();
expect( submitButton ).toBeDisabled();
expect( submitButton ).toBeVisible();
}
diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js
index 758c3bf51bec3d..cc4af719e63ec3 100644
--- a/packages/block-editor/src/components/url-input/index.js
+++ b/packages/block-editor/src/components/url-input/index.js
@@ -468,7 +468,7 @@ class URLInput extends Component {
'aria-label': label ? undefined : __( 'URL' ), // Ensure input always has an accessible label
'aria-expanded': showSuggestions,
'aria-autocomplete': 'list',
- 'aria-controls': suggestionsListboxId,
+ 'aria-owns': suggestionsListboxId,
'aria-activedescendant':
selectedSuggestion !== null
? `${ suggestionOptionIdPrefix }-${ selectedSuggestion }`
@@ -531,7 +531,8 @@ class URLInput extends Component {
tabIndex: '-1',
id: `${ suggestionOptionIdPrefix }-${ index }`,
ref: this.bindSuggestionNode( index ),
- 'aria-selected': index === selectedSuggestion,
+ 'aria-selected':
+ index === selectedSuggestion ? true : undefined,
};
};