From d01d6501f7768f1afd3a6805ac2245b68da35888 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 10 Jun 2025 12:56:25 -0400 Subject: [PATCH] MM-63050 Improve screen reader support for SuggestionList (#31228) * Remove aria-live from SuggestionBox aria-live isn't the idiomatic way to make the autocomplete accessible to screen readers. Instead, we should've used aria-activedescendant which was done for the at-mention autocomplete in a previous ticket, but that didn't apply to other types of autocompletes. That lead to the at-mention autocomplete being too noisy (as there were two different things telling the user what the results were), and it meant that other types of autocompletes didn't function. This needs a couple direct followups: 1. The E2E tests need to be updated since they test for aria-live. 2. The Suggestion items for other types of autocompletes need IDs for the aria-activedescendant to work. * Consistently set IDs for all SuggestionList items Instead of leaving it up to the individual Suggestion components, this'll ensure that a11y support works for all of them going forward as long as they properly forward other props to the underlying li element. I would've preferred if each instance of SuggestionList had unique IDs, but they currently all use the ID suggestionList, and I didn't want to update that ID across 55 different Cypress tests. There's should only be a single SuggestionList visible at a time, so the current situation is fine enough. * Remove textboxId from AtMentionProvider * Change suggestion list to scroll using IDs instead of findDOMNode and refs * Add an aria-label to SuggestionList * Make all Suggestion components use the option role * Add number of results to SuggestionList readout * Change SuggestionBox to only set aria-expanded with results * Add tests for ARIA of SuggestionBox * Address feedback --- .../archive_channel_operations_spec.ts | 4 +- .../accessibility_input_fields_spec.ts | 39 +++---- .../__snapshots__/textbox.test.tsx.snap | 4 - .../__snapshots__/search_bar.test.tsx.snap | 25 ----- .../at_mention_suggestion.test.tsx.snap | 10 -- .../at_mention_provider.tsx | 23 ++-- .../at_mention_suggestion.test.tsx | 2 - .../at_mention_suggestion.tsx | 3 - .../search_suggestion_list.test.tsx | 46 -------- .../suggestion/search_suggestion_list.tsx | 45 +------- .../src/components/suggestion/suggestion.tsx | 5 +- .../suggestion_box/suggestion_box.jsx | 13 +-- .../suggestion_box/suggestion_box.test.tsx | 98 +++++++++++++++++ .../suggestion/suggestion_list.test.tsx | 36 ------ .../components/suggestion/suggestion_list.tsx | 104 ++++++++---------- .../suggestion/switch_channel_provider.tsx | 11 -- .../src/components/textbox/textbox.tsx | 2 - webapp/channels/src/i18n/en.json | 2 + 18 files changed, 173 insertions(+), 299 deletions(-) delete mode 100644 webapp/channels/src/components/suggestion/search_suggestion_list.test.tsx delete mode 100644 webapp/channels/src/components/suggestion/suggestion_list.test.tsx diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_operations_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_operations_spec.ts index fa68c4d202..b950a7e3b8 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_operations_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_operations_spec.ts @@ -56,7 +56,7 @@ describe('Leave an archived channel', () => { // * The archived channel appears in channel switcher search results cy.get('#suggestionList').should('be.visible'); - cy.get('#suggestionList').find(`#quickSwitchInput_${testChannel.id}`).should('be.visible'); + cy.get('#suggestionList').find(`#suggestionList_item_${testChannel.id}`).should('be.visible'); // # Reload the app (refresh the web page) cy.reload().then(() => { @@ -67,7 +67,7 @@ describe('Leave an archived channel', () => { cy.get('#quickSwitchInput').type(testChannel.display_name).then(() => { // * The archived channel appears in channel switcher search results cy.get('#suggestionList').should('be.visible'); - cy.get('#suggestionList').find(`#quickSwitchInput_${testChannel.id}`).should('be.visible'); + cy.get('#suggestionList').find(`#suggestionList_item_${testChannel.id}`).should('be.visible'); }); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts index 99649337fa..26c502fbdd 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts @@ -86,10 +86,10 @@ describe('Verify Accessibility Support in different input fields', () => { cy.get('#searchHints').should('be.visible'); // # Ensure User list is cached once in UI - cy.uiGetSearchBox().type('from:').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetSearchBox().type('from:').wait(TIMEOUTS.ONE_SEC); // # Trigger the user autocomplete again - cy.uiGetSearchBox().first().clear().type('from:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); + cy.uiGetSearchBox().first().clear().type('from:').wait(TIMEOUTS.ONE_SEC).type('{downarrow}{downarrow}'); // * Verify Accessibility Support in search autocomplete verifySearchAutocomplete(2); @@ -103,10 +103,10 @@ describe('Verify Accessibility Support in different input fields', () => { verifySearchAutocomplete(3); // # Type the in: filter and ensure channel list is cached once - cy.uiGetSearchBox().first().clear().type('in:').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetSearchBox().first().clear().type('in:').wait(TIMEOUTS.ONE_SEC); // # Trigger the channel autocomplete again - cy.uiGetSearchBox().first().clear().type('in:').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); + cy.uiGetSearchBox().first().clear().type('in:').wait(TIMEOUTS.ONE_SEC).type('{downarrow}{downarrow}'); // * Verify Accessibility Support in search autocomplete verifySearchAutocomplete(2, 'channel'); @@ -125,7 +125,7 @@ describe('Verify Accessibility Support in different input fields', () => { cy.uiGetPostTextBox().should('have.attr', 'placeholder', `Write to ${testChannel.display_name}`).clear().focus(); // # Ensure User list is cached once in UI - cy.uiGetPostTextBox().type('@').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetPostTextBox().type('@').wait(TIMEOUTS.ONE_SEC); // # Select the first user in the list cy.get('#suggestionList').find('.suggestion-list__item').eq(0).within((el) => { @@ -135,7 +135,7 @@ describe('Verify Accessibility Support in different input fields', () => { }); // # Trigger the user autocomplete again - cy.uiGetPostTextBox().clear().type('@').wait(TIMEOUTS.FIVE_SEC).type('{uparrow}{uparrow}{downarrow}'); + cy.uiGetPostTextBox().clear().type('@').wait(TIMEOUTS.ONE_SEC).type('{uparrow}{uparrow}{downarrow}'); // * Verify Accessibility Support in message autocomplete verifyMessageAutocomplete(1); @@ -147,19 +147,19 @@ describe('Verify Accessibility Support in different input fields', () => { verifyMessageAutocomplete(0); // # Trigger the channel autocomplete filter and ensure channel list is cached once - cy.uiGetPostTextBox().clear().type('~').wait(TIMEOUTS.FIVE_SEC); + cy.uiGetPostTextBox().clear().type('~').wait(TIMEOUTS.ONE_SEC); // # Trigger the channel autocomplete again cy.uiGetPostTextBox().clear().type('~').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}'); // * Verify Accessibility Support in message autocomplete - verifyMessageAutocomplete(2, 'channel'); + verifyMessageAutocomplete(2); // # Press Up arrow and verify if focus changes cy.focused().type('{downarrow}{uparrow}{uparrow}'); // * Verify Accessibility Support in message autocomplete - verifyMessageAutocomplete(1, 'channel'); + verifyMessageAutocomplete(1); }); }); }); @@ -307,20 +307,11 @@ function verifySearchAutocomplete(index, type = 'user') { }); } -function verifyMessageAutocomplete(index, type = 'user') { - cy.get('#suggestionList').find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected').within((el) => { - if (type === 'user') { - cy.get('.suggestion-list__ellipsis').invoke('text').then((fullText) => { - cy.get('.suggestion-list__main').invoke('text').then((username) => { - const usernameFullNameNickName = getUserMentionAriaLabel(`${username} ${fullText.split(username)[1]}`); - cy.wrap(el).parents('.textarea-wrapper').find('.sr-only').should('have.attr', 'aria-live', 'polite').and('have.text', usernameFullNameNickName); - }); - }); - } else if (type === 'channel') { - cy.wrap(el).invoke('text').then((text) => { - const channel = text.split('~')[0].toLowerCase().trim(); - cy.wrap(el).parents('.textarea-wrapper').find('.sr-only').should('have.attr', 'aria-live', 'polite').and('have.text', channel); - }); - } +function verifyMessageAutocomplete(index) { + cy.get('#suggestionList').find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected'); + cy.get('#suggestionList').find('.suggestion-list__item').eq(index).invoke('attr', 'id').then((selectedId) => { + cy.wrap(selectedId).should('not.equal', ''); + + cy.uiGetPostTextBox().should('have.attr', 'aria-activedescendant', selectedId); }); } diff --git a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap index dfc6237122..090cacac25 100644 --- a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap @@ -110,7 +110,6 @@ exports[`components/TextBox should match snapshot with additional, optional prop "priorityProfiles": undefined, "requestStarted": false, "searchAssociatedGroupsForReference": [Function], - "textboxId": "someid", "triggerCharacter": "@", "useChannelMentions": true, }, @@ -258,7 +257,6 @@ exports[`components/TextBox should match snapshot with required props 1`] = ` "priorityProfiles": undefined, "requestStarted": false, "searchAssociatedGroupsForReference": [Function], - "textboxId": "someid", "triggerCharacter": "@", "useChannelMentions": true, }, @@ -406,7 +404,6 @@ exports[`components/TextBox should throw error when new property is too long 1`] "priorityProfiles": undefined, "requestStarted": false, "searchAssociatedGroupsForReference": [Function], - "textboxId": "someid", "triggerCharacter": "@", "useChannelMentions": true, }, @@ -554,7 +551,6 @@ exports[`components/TextBox should throw error when value is too long 1`] = ` "priorityProfiles": undefined, "requestStarted": false, "searchAssociatedGroupsForReference": [Function], - "textboxId": "someid", "triggerCharacter": "@", "useChannelMentions": true, }, diff --git a/webapp/channels/src/components/search_bar/__snapshots__/search_bar.test.tsx.snap b/webapp/channels/src/components/search_bar/__snapshots__/search_bar.test.tsx.snap index 6715a2b019..12d8bfdc2f 100644 --- a/webapp/channels/src/components/search_bar/__snapshots__/search_bar.test.tsx.snap +++ b/webapp/channels/src/components/search_bar/__snapshots__/search_bar.test.tsx.snap @@ -23,11 +23,6 @@ exports[`components/search_bar/SearchBar should match snapshot with search 1`] =
-