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, }; };