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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
09a2037b61
Коммит
d01d6501f7
@@ -56,7 +56,7 @@ describe('Leave an archived channel', () => {
|
|||||||
|
|
||||||
// * The archived channel appears in channel switcher search results
|
// * The archived channel appears in channel switcher search results
|
||||||
cy.get('#suggestionList').should('be.visible');
|
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)
|
// # Reload the app (refresh the web page)
|
||||||
cy.reload().then(() => {
|
cy.reload().then(() => {
|
||||||
@@ -67,7 +67,7 @@ describe('Leave an archived channel', () => {
|
|||||||
cy.get('#quickSwitchInput').type(testChannel.display_name).then(() => {
|
cy.get('#quickSwitchInput').type(testChannel.display_name).then(() => {
|
||||||
// * The archived channel appears in channel switcher search results
|
// * The archived channel appears in channel switcher search results
|
||||||
cy.get('#suggestionList').should('be.visible');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ describe('Verify Accessibility Support in different input fields', () => {
|
|||||||
cy.get('#searchHints').should('be.visible');
|
cy.get('#searchHints').should('be.visible');
|
||||||
|
|
||||||
// # Ensure User list is cached once in UI
|
// # 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
|
// # 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
|
// * Verify Accessibility Support in search autocomplete
|
||||||
verifySearchAutocomplete(2);
|
verifySearchAutocomplete(2);
|
||||||
@@ -103,10 +103,10 @@ describe('Verify Accessibility Support in different input fields', () => {
|
|||||||
verifySearchAutocomplete(3);
|
verifySearchAutocomplete(3);
|
||||||
|
|
||||||
// # Type the in: filter and ensure channel list is cached once
|
// # 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
|
// # 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
|
// * Verify Accessibility Support in search autocomplete
|
||||||
verifySearchAutocomplete(2, 'channel');
|
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();
|
cy.uiGetPostTextBox().should('have.attr', 'placeholder', `Write to ${testChannel.display_name}`).clear().focus();
|
||||||
|
|
||||||
// # Ensure User list is cached once in UI
|
// # 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
|
// # Select the first user in the list
|
||||||
cy.get('#suggestionList').find('.suggestion-list__item').eq(0).within((el) => {
|
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
|
// # 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
|
// * Verify Accessibility Support in message autocomplete
|
||||||
verifyMessageAutocomplete(1);
|
verifyMessageAutocomplete(1);
|
||||||
@@ -147,19 +147,19 @@ describe('Verify Accessibility Support in different input fields', () => {
|
|||||||
verifyMessageAutocomplete(0);
|
verifyMessageAutocomplete(0);
|
||||||
|
|
||||||
// # Trigger the channel autocomplete filter and ensure channel list is cached once
|
// # 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
|
// # Trigger the channel autocomplete again
|
||||||
cy.uiGetPostTextBox().clear().type('~').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}');
|
cy.uiGetPostTextBox().clear().type('~').wait(TIMEOUTS.FIVE_SEC).type('{downarrow}{downarrow}');
|
||||||
|
|
||||||
// * Verify Accessibility Support in message autocomplete
|
// * Verify Accessibility Support in message autocomplete
|
||||||
verifyMessageAutocomplete(2, 'channel');
|
verifyMessageAutocomplete(2);
|
||||||
|
|
||||||
// # Press Up arrow and verify if focus changes
|
// # Press Up arrow and verify if focus changes
|
||||||
cy.focused().type('{downarrow}{uparrow}{uparrow}');
|
cy.focused().type('{downarrow}{uparrow}{uparrow}');
|
||||||
|
|
||||||
// * Verify Accessibility Support in message autocomplete
|
// * 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') {
|
function verifyMessageAutocomplete(index) {
|
||||||
cy.get('#suggestionList').find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected').within((el) => {
|
cy.get('#suggestionList').find('.suggestion-list__item').eq(index).should('be.visible').and('have.class', 'suggestion--selected');
|
||||||
if (type === 'user') {
|
cy.get('#suggestionList').find('.suggestion-list__item').eq(index).invoke('attr', 'id').then((selectedId) => {
|
||||||
cy.get('.suggestion-list__ellipsis').invoke('text').then((fullText) => {
|
cy.wrap(selectedId).should('not.equal', '');
|
||||||
cy.get('.suggestion-list__main').invoke('text').then((username) => {
|
|
||||||
const usernameFullNameNickName = getUserMentionAriaLabel(`${username} ${fullText.split(username)[1]}`);
|
cy.uiGetPostTextBox().should('have.attr', 'aria-activedescendant', selectedId);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ exports[`components/TextBox should match snapshot with additional, optional prop
|
|||||||
"priorityProfiles": undefined,
|
"priorityProfiles": undefined,
|
||||||
"requestStarted": false,
|
"requestStarted": false,
|
||||||
"searchAssociatedGroupsForReference": [Function],
|
"searchAssociatedGroupsForReference": [Function],
|
||||||
"textboxId": "someid",
|
|
||||||
"triggerCharacter": "@",
|
"triggerCharacter": "@",
|
||||||
"useChannelMentions": true,
|
"useChannelMentions": true,
|
||||||
},
|
},
|
||||||
@@ -258,7 +257,6 @@ exports[`components/TextBox should match snapshot with required props 1`] = `
|
|||||||
"priorityProfiles": undefined,
|
"priorityProfiles": undefined,
|
||||||
"requestStarted": false,
|
"requestStarted": false,
|
||||||
"searchAssociatedGroupsForReference": [Function],
|
"searchAssociatedGroupsForReference": [Function],
|
||||||
"textboxId": "someid",
|
|
||||||
"triggerCharacter": "@",
|
"triggerCharacter": "@",
|
||||||
"useChannelMentions": true,
|
"useChannelMentions": true,
|
||||||
},
|
},
|
||||||
@@ -406,7 +404,6 @@ exports[`components/TextBox should throw error when new property is too long 1`]
|
|||||||
"priorityProfiles": undefined,
|
"priorityProfiles": undefined,
|
||||||
"requestStarted": false,
|
"requestStarted": false,
|
||||||
"searchAssociatedGroupsForReference": [Function],
|
"searchAssociatedGroupsForReference": [Function],
|
||||||
"textboxId": "someid",
|
|
||||||
"triggerCharacter": "@",
|
"triggerCharacter": "@",
|
||||||
"useChannelMentions": true,
|
"useChannelMentions": true,
|
||||||
},
|
},
|
||||||
@@ -554,7 +551,6 @@ exports[`components/TextBox should throw error when value is too long 1`] = `
|
|||||||
"priorityProfiles": undefined,
|
"priorityProfiles": undefined,
|
||||||
"requestStarted": false,
|
"requestStarted": false,
|
||||||
"searchAssociatedGroupsForReference": [Function],
|
"searchAssociatedGroupsForReference": [Function],
|
||||||
"textboxId": "someid",
|
|
||||||
"triggerCharacter": "@",
|
"triggerCharacter": "@",
|
||||||
"useChannelMentions": true,
|
"useChannelMentions": true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ exports[`components/search_bar/SearchBar should match snapshot with search 1`] =
|
|||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
class="sr-only"
|
|
||||||
role="alert"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="input-wrapper"
|
class="input-wrapper"
|
||||||
>
|
>
|
||||||
@@ -98,11 +93,6 @@ exports[`components/search_bar/SearchBar should match snapshot with search, with
|
|||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
class="sr-only"
|
|
||||||
role="alert"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="input-wrapper"
|
class="input-wrapper"
|
||||||
>
|
>
|
||||||
@@ -165,11 +155,6 @@ exports[`components/search_bar/SearchBar should match snapshot without search 1`
|
|||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
class="sr-only"
|
|
||||||
role="alert"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="input-wrapper"
|
class="input-wrapper"
|
||||||
>
|
>
|
||||||
@@ -228,11 +213,6 @@ exports[`components/search_bar/SearchBar should match snapshot without search, w
|
|||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
class="sr-only"
|
|
||||||
role="alert"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="input-wrapper"
|
class="input-wrapper"
|
||||||
>
|
>
|
||||||
@@ -283,11 +263,6 @@ exports[`components/search_bar/SearchBar should match snapshot without search, w
|
|||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
aria-live="polite"
|
|
||||||
class="sr-only"
|
|
||||||
role="alert"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="input-wrapper"
|
class="input-wrapper"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
|
|||||||
"id": "userid2",
|
"id": "userid2",
|
||||||
"last_name": "b",
|
"last_name": "b",
|
||||||
"nickname": "c",
|
"nickname": "c",
|
||||||
"textboxId": "post_textbox",
|
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +19,6 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
|
|||||||
>
|
>
|
||||||
<SuggestionContainer
|
<SuggestionContainer
|
||||||
data-testid="mentionSuggestion_user2"
|
data-testid="mentionSuggestion_user2"
|
||||||
id="post_textbox_@user2"
|
|
||||||
isSelection={false}
|
isSelection={false}
|
||||||
item={
|
item={
|
||||||
Object {
|
Object {
|
||||||
@@ -28,20 +26,17 @@ exports[`at mention suggestion Should display nick name of non signed in user 1`
|
|||||||
"id": "userid2",
|
"id": "userid2",
|
||||||
"last_name": "b",
|
"last_name": "b",
|
||||||
"nickname": "c",
|
"nickname": "c",
|
||||||
"textboxId": "post_textbox",
|
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
matchedPretext="@"
|
matchedPretext="@"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
onMouseMove={[MockFunction]}
|
onMouseMove={[MockFunction]}
|
||||||
role="option"
|
|
||||||
term="@user"
|
term="@user"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
className="suggestion-list__item"
|
className="suggestion-list__item"
|
||||||
data-testid="mentionSuggestion_user2"
|
data-testid="mentionSuggestion_user2"
|
||||||
id="post_textbox_@user2"
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onMouseMove={[Function]}
|
onMouseMove={[Function]}
|
||||||
role="option"
|
role="option"
|
||||||
@@ -108,7 +103,6 @@ exports[`at mention suggestion Should not display nick name of the signed in use
|
|||||||
"isCurrentUser": true,
|
"isCurrentUser": true,
|
||||||
"last_name": "b",
|
"last_name": "b",
|
||||||
"nickname": "c",
|
"nickname": "c",
|
||||||
"textboxId": "post_textbox",
|
|
||||||
"username": "user",
|
"username": "user",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +113,6 @@ exports[`at mention suggestion Should not display nick name of the signed in use
|
|||||||
>
|
>
|
||||||
<SuggestionContainer
|
<SuggestionContainer
|
||||||
data-testid="mentionSuggestion_user"
|
data-testid="mentionSuggestion_user"
|
||||||
id="post_textbox_@user"
|
|
||||||
isSelection={false}
|
isSelection={false}
|
||||||
item={
|
item={
|
||||||
Object {
|
Object {
|
||||||
@@ -128,20 +121,17 @@ exports[`at mention suggestion Should not display nick name of the signed in use
|
|||||||
"isCurrentUser": true,
|
"isCurrentUser": true,
|
||||||
"last_name": "b",
|
"last_name": "b",
|
||||||
"nickname": "c",
|
"nickname": "c",
|
||||||
"textboxId": "post_textbox",
|
|
||||||
"username": "user",
|
"username": "user",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
matchedPretext="@"
|
matchedPretext="@"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
onMouseMove={[MockFunction]}
|
onMouseMove={[MockFunction]}
|
||||||
role="option"
|
|
||||||
term="@user"
|
term="@user"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
className="suggestion-list__item"
|
className="suggestion-list__item"
|
||||||
data-testid="mentionSuggestion_user"
|
data-testid="mentionSuggestion_user"
|
||||||
id="post_textbox_@user"
|
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onMouseMove={[Function]}
|
onMouseMove={[Function]}
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type Results = {
|
|||||||
type ResultsCallback = (results: Results) => void;
|
type ResultsCallback = (results: Results) => void;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
textboxId?: string;
|
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
|
autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
|
||||||
@@ -59,7 +58,6 @@ export type Props = {
|
|||||||
// users in the channel and users not in the channel. It mixes together results from the local
|
// users in the channel and users not in the channel. It mixes together results from the local
|
||||||
// store with results fetched from the server.
|
// store with results fetched from the server.
|
||||||
export default class AtMentionProvider extends Provider {
|
export default class AtMentionProvider extends Provider {
|
||||||
public textboxId?: string;
|
|
||||||
public currentUserId: string;
|
public currentUserId: string;
|
||||||
public channelId: string;
|
public channelId: string;
|
||||||
public autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
|
public autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
|
||||||
@@ -77,9 +75,8 @@ export default class AtMentionProvider extends Provider {
|
|||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
const {currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles, textboxId} = props;
|
const {currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles} = props;
|
||||||
|
|
||||||
this.textboxId = textboxId;
|
|
||||||
this.currentUserId = currentUserId;
|
this.currentUserId = currentUserId;
|
||||||
this.channelId = channelId;
|
this.channelId = channelId;
|
||||||
this.autocompleteUsersInChannel = autocompleteUsersInChannel;
|
this.autocompleteUsersInChannel = autocompleteUsersInChannel;
|
||||||
@@ -96,8 +93,7 @@ export default class AtMentionProvider extends Provider {
|
|||||||
this.addLastViewAtToProfiles = makeAddLastViewAtToProfiles();
|
this.addLastViewAtToProfiles = makeAddLastViewAtToProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
setProps({currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles, textboxId}: Props) {
|
setProps({currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles}: Props) {
|
||||||
this.textboxId = textboxId;
|
|
||||||
this.currentUserId = currentUserId;
|
this.currentUserId = currentUserId;
|
||||||
this.channelId = channelId;
|
this.channelId = channelId;
|
||||||
this.autocompleteUsersInChannel = autocompleteUsersInChannel;
|
this.autocompleteUsersInChannel = autocompleteUsersInChannel;
|
||||||
@@ -366,24 +362,19 @@ export default class AtMentionProvider extends Provider {
|
|||||||
} else if (this.lastPrefixWithNoResults === this.latestPrefix) {
|
} else if (this.lastPrefixWithNoResults === this.latestPrefix) {
|
||||||
this.lastPrefixWithNoResults = '';
|
this.lastPrefixWithNoResults = '';
|
||||||
}
|
}
|
||||||
const mentions: string[] = [];
|
const mentions = items.map((item) => {
|
||||||
|
|
||||||
// Add the textboxId for each suggestions
|
|
||||||
const modifiedItems = items.map((item) => {
|
|
||||||
if (item.username) {
|
if (item.username) {
|
||||||
mentions.push('@' + item.username);
|
return '@' + item.username;
|
||||||
} else if (item.name) {
|
} else if (item.name) {
|
||||||
mentions.push('@' + item.name);
|
return '@' + item.name;
|
||||||
} else {
|
|
||||||
mentions.push('');
|
|
||||||
}
|
}
|
||||||
return {...item, textboxId: this.textboxId};
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
resultCallback({
|
resultCallback({
|
||||||
matchedPretext: `@${this.latestPrefix}`,
|
matchedPretext: `@${this.latestPrefix}`,
|
||||||
terms: mentions,
|
terms: mentions,
|
||||||
items: modifiedItems,
|
items,
|
||||||
component: AtMentionSuggestion,
|
component: AtMentionSuggestion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe('at mention suggestion', () => {
|
|||||||
last_name: 'b',
|
last_name: 'b',
|
||||||
nickname: 'c',
|
nickname: 'c',
|
||||||
isCurrentUser: true,
|
isCurrentUser: true,
|
||||||
textboxId: 'post_textbox',
|
|
||||||
} as Item;
|
} as Item;
|
||||||
|
|
||||||
const userid2 = {
|
const userid2 = {
|
||||||
@@ -29,7 +28,6 @@ describe('at mention suggestion', () => {
|
|||||||
first_name: 'a',
|
first_name: 'a',
|
||||||
last_name: 'b',
|
last_name: 'b',
|
||||||
nickname: 'c',
|
nickname: 'c',
|
||||||
textboxId: 'post_textbox',
|
|
||||||
} as Item;
|
} as Item;
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export interface Item extends UserProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
textboxId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Group extends Item {
|
interface Group extends Item {
|
||||||
@@ -188,10 +187,8 @@ const AtMentionSuggestion = React.forwardRef<HTMLLIElement, SuggestionProps<Item
|
|||||||
return (
|
return (
|
||||||
<SuggestionContainer
|
<SuggestionContainer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role='option'
|
|
||||||
{...props}
|
{...props}
|
||||||
data-testid={`mentionSuggestion_${itemname}`}
|
data-testid={`mentionSuggestion_${itemname}`}
|
||||||
id={`${item.textboxId}_@${itemname}`}
|
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className='suggestion-list__ellipsis'>
|
<span className='suggestion-list__ellipsis'>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {shallow} from 'enzyme';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import SearchSuggestionList from 'components/suggestion/search_suggestion_list';
|
|
||||||
|
|
||||||
import {TestHelper} from 'utils/test_helper';
|
|
||||||
|
|
||||||
describe('components/SearchSuggestionList', () => {
|
|
||||||
const baseProps = {
|
|
||||||
open: true,
|
|
||||||
onCompleteWord: jest.fn(),
|
|
||||||
pretext: '',
|
|
||||||
cleared: false,
|
|
||||||
matchedPretext: [],
|
|
||||||
items: [],
|
|
||||||
terms: [],
|
|
||||||
selection: '',
|
|
||||||
components: [],
|
|
||||||
onItemHover: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
test('should not throw error when currentLabel is null and label is generated', () => {
|
|
||||||
const userProfile = TestHelper.getUserMock();
|
|
||||||
const item = {
|
|
||||||
...userProfile,
|
|
||||||
type: 'item_type',
|
|
||||||
display_name: 'item_display_name',
|
|
||||||
name: 'item_name',
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(
|
|
||||||
<SearchSuggestionList
|
|
||||||
{...baseProps}
|
|
||||||
ariaLiveRef={React.createRef()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const instance = wrapper.instance() as SearchSuggestionList;
|
|
||||||
instance.currentLabel = null as any;
|
|
||||||
|
|
||||||
instance.generateLabel(item);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,52 +9,18 @@ import Popover from 'components/widgets/popover';
|
|||||||
|
|
||||||
import Constants from 'utils/constants';
|
import Constants from 'utils/constants';
|
||||||
|
|
||||||
import type {UserProfile} from './command_provider/app_command_parser/app_command_parser_dependencies';
|
|
||||||
import type {Props} from './suggestion_list';
|
import type {Props} from './suggestion_list';
|
||||||
import SuggestionList from './suggestion_list';
|
import SuggestionList from './suggestion_list';
|
||||||
|
|
||||||
interface Item extends UserProfile {
|
|
||||||
type: string;
|
|
||||||
display_name: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class SearchSuggestionList extends SuggestionList {
|
export default class SearchSuggestionList extends SuggestionList {
|
||||||
popoverRef: React.RefObject<BSPopover>;
|
popoverRef: React.RefObject<BSPopover>;
|
||||||
itemsContainerRef: React.RefObject<HTMLDivElement>;
|
itemsContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
suggestionReadOut: React.RefObject<HTMLDivElement>;
|
|
||||||
currentLabel: string;
|
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.popoverRef = React.createRef();
|
this.popoverRef = React.createRef();
|
||||||
this.itemsContainerRef = React.createRef();
|
this.itemsContainerRef = React.createRef();
|
||||||
this.suggestionReadOut = React.createRef();
|
|
||||||
this.currentLabel = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
generateLabel(item: Item) {
|
|
||||||
if (item.username) {
|
|
||||||
this.currentLabel = item.username;
|
|
||||||
if ((item.first_name || item.last_name) && item.nickname) {
|
|
||||||
this.currentLabel += ` ${item.first_name} ${item.last_name} ${item.nickname}`;
|
|
||||||
} else if (item.nickname) {
|
|
||||||
this.currentLabel += ` ${item.nickname}`;
|
|
||||||
} else if (item.first_name || item.last_name) {
|
|
||||||
this.currentLabel += ` ${item.first_name} ${item.last_name}`;
|
|
||||||
}
|
|
||||||
} else if (item.type === Constants.DM_CHANNEL || item.type === Constants.GM_CHANNEL) {
|
|
||||||
this.currentLabel = item.display_name;
|
|
||||||
} else {
|
|
||||||
this.currentLabel = item.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentLabel) {
|
|
||||||
this.currentLabel = this.currentLabel.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.announceLabel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getContent = () => {
|
getContent = () => {
|
||||||
@@ -127,14 +93,10 @@ export default class SearchSuggestionList extends SuggestionList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelection) {
|
|
||||||
this.currentItem = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
<Component
|
<Component
|
||||||
key={term}
|
key={term}
|
||||||
ref={(ref: React.RefObject<HTMLDivElement>) => this.itemRefs.set(term, ref)}
|
id={`sbrSearchBox_item_${term}`}
|
||||||
item={item}
|
item={item}
|
||||||
term={term}
|
term={term}
|
||||||
matchedPretext={this.props.matchedPretext[i]}
|
matchedPretext={this.props.matchedPretext[i]}
|
||||||
@@ -152,11 +114,6 @@ export default class SearchSuggestionList extends SuggestionList {
|
|||||||
className='search-help-popover autocomplete visible'
|
className='search-help-popover autocomplete visible'
|
||||||
placement='bottom'
|
placement='bottom'
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
ref={this.suggestionReadOut}
|
|
||||||
aria-live='polite'
|
|
||||||
className='hidden-label'
|
|
||||||
/>
|
|
||||||
<div ref={this.itemsContainerRef}>
|
<div ref={this.itemsContainerRef}>
|
||||||
{items}
|
{items}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {useCallback} from 'react';
|
import React, {useCallback} from 'react';
|
||||||
|
|
||||||
export interface SuggestionProps<Item> extends Omit<React.HTMLAttributes<HTMLLIElement>, 'onClick' | 'onMouseMove'> {
|
export interface SuggestionProps<Item> extends Omit<React.HTMLAttributes<HTMLLIElement>, 'onClick' | 'onMouseMove' | 'role'> {
|
||||||
// eslint-disable-next-line react/no-unused-prop-types
|
// eslint-disable-next-line react/no-unused-prop-types
|
||||||
item: Item;
|
item: Item;
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ const SuggestionContainer = React.forwardRef<HTMLLIElement, SuggestionProps<unkn
|
|||||||
onClick,
|
onClick,
|
||||||
onMouseMove,
|
onMouseMove,
|
||||||
|
|
||||||
role = 'button',
|
|
||||||
tabIndex = -1,
|
tabIndex = -1,
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
@@ -50,9 +49,9 @@ const SuggestionContainer = React.forwardRef<HTMLLIElement, SuggestionProps<unkn
|
|||||||
<li
|
<li
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})}
|
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})}
|
||||||
|
role='option'
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
role={role}
|
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ export default class SuggestionBox extends React.PureComponent {
|
|||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.suggestionReadOut = React.createRef();
|
|
||||||
|
|
||||||
// Keep track of whether we're composing a CJK character so we can make suggestions for partial characters
|
// Keep track of whether we're composing a CJK character so we can make suggestions for partial characters
|
||||||
this.composing = false;
|
this.composing = false;
|
||||||
@@ -817,22 +816,15 @@ export default class SuggestionBox extends React.PureComponent {
|
|||||||
ref={this.setContainerRef}
|
ref={this.setContainerRef}
|
||||||
className={this.props.containerClass}
|
className={this.props.containerClass}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
ref={this.suggestionReadOut}
|
|
||||||
aria-live='polite'
|
|
||||||
role='alert'
|
|
||||||
className='sr-only'
|
|
||||||
/>
|
|
||||||
<QuickInput
|
<QuickInput
|
||||||
ref={this.inputRef}
|
ref={this.inputRef}
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
{...props}
|
{...props}
|
||||||
aria-controls='suggestionList'
|
aria-controls='suggestionList'
|
||||||
role='combobox'
|
role='combobox'
|
||||||
{...(this.state.selection && {'aria-activedescendant': `${props.id}_${this.state.selection}`}
|
aria-activedescendant={this.state.selection ? `suggestionList_item_${this.state.selection}` : undefined}
|
||||||
)}
|
|
||||||
aria-autocomplete='list'
|
aria-autocomplete='list'
|
||||||
aria-expanded={this.state.focused || this.props.forceSuggestionsWhenBlur}
|
aria-expanded={(this.state.focused || this.props.forceSuggestionsWhenBlur) && this.state.items.length > 0}
|
||||||
onInput={this.handleChange}
|
onInput={this.handleChange}
|
||||||
onCompositionStart={this.handleCompositionStart}
|
onCompositionStart={this.handleCompositionStart}
|
||||||
onCompositionUpdate={this.handleCompositionUpdate}
|
onCompositionUpdate={this.handleCompositionUpdate}
|
||||||
@@ -841,7 +833,6 @@ export default class SuggestionBox extends React.PureComponent {
|
|||||||
/>
|
/>
|
||||||
{(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.state.presentationType === 'text' && (
|
{(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.state.presentationType === 'text' && (
|
||||||
<SuggestionListComponent
|
<SuggestionListComponent
|
||||||
ariaLiveRef={this.suggestionReadOut}
|
|
||||||
open={this.state.focused || this.props.forceSuggestionsWhenBlur}
|
open={this.state.focused || this.props.forceSuggestionsWhenBlur}
|
||||||
pretext={this.pretext}
|
pretext={this.pretext}
|
||||||
position={this.getListPosition(listPosition)}
|
position={this.getListPosition(listPosition)}
|
||||||
|
|||||||
@@ -4,13 +4,25 @@
|
|||||||
import React, {useCallback, useState} from 'react';
|
import React, {useCallback, useState} from 'react';
|
||||||
|
|
||||||
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
|
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
|
||||||
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
import SuggestionBox from './suggestion_box';
|
import SuggestionBox from './suggestion_box';
|
||||||
|
|
||||||
|
import AtMentionProvider from '../at_mention_provider';
|
||||||
import type {ResultsCallback} from '../provider';
|
import type {ResultsCallback} from '../provider';
|
||||||
import Provider from '../provider';
|
import Provider from '../provider';
|
||||||
import SuggestionList from '../suggestion_list';
|
import SuggestionList from '../suggestion_list';
|
||||||
|
|
||||||
|
jest.mock('utils/utils', () => ({
|
||||||
|
...jest.requireActual('utils/utils'),
|
||||||
|
getSuggestionBoxAlgn() {
|
||||||
|
return {
|
||||||
|
pixelsToMoveX: 0,
|
||||||
|
pixelsToMoveY: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
function TestWrapper(props: React.ComponentPropsWithoutRef<typeof SuggestionBox>) {
|
function TestWrapper(props: React.ComponentPropsWithoutRef<typeof SuggestionBox>) {
|
||||||
// eslint-disable-next-line react/prop-types
|
// eslint-disable-next-line react/prop-types
|
||||||
const [value, setValue] = useState(props.value);
|
const [value, setValue] = useState(props.value);
|
||||||
@@ -241,4 +253,90 @@ describe('SuggestionBox', () => {
|
|||||||
// expect(onSuggestionsReceived).toHaveBeenCalledTimes(1);
|
// expect(onSuggestionsReceived).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.getByPlaceholderText('test input')).toHaveValue('@use@use This is important');
|
expect(screen.getByPlaceholderText('test input')).toHaveValue('@use@use This is important');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard support and ARIA', async () => {
|
||||||
|
const channelId = 'channelId';
|
||||||
|
const userA = TestHelper.getUserMock({id: 'userA', username: 'apple'});
|
||||||
|
const userB = TestHelper.getUserMock({id: 'userB', username: 'banana'});
|
||||||
|
|
||||||
|
const provider = new AtMentionProvider({
|
||||||
|
autocompleteGroups: null,
|
||||||
|
autocompleteUsersInChannel: jest.fn().mockResolvedValue({data: []}),
|
||||||
|
priorityProfiles: [],
|
||||||
|
channelId: 'channelId',
|
||||||
|
currentUserId: 'currentUserId',
|
||||||
|
searchAssociatedGroupsForReference: jest.fn().mockResolvedValue({data: []}),
|
||||||
|
useChannelMentions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithContext(
|
||||||
|
<TestWrapper
|
||||||
|
{...makeBaseProps()}
|
||||||
|
providers={[provider]}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
entities: {
|
||||||
|
users: {
|
||||||
|
profilesInChannel: {
|
||||||
|
[channelId]: new Set([userA.id, userB.id]),
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
[userA.id]: userA,
|
||||||
|
[userB.id]: userB,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('test input');
|
||||||
|
userEvent.click(input);
|
||||||
|
|
||||||
|
// Start without showing the autocomplete list
|
||||||
|
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-controls')!)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Type something that shouldn't trigger the autocomplete
|
||||||
|
await userEvent.keyboard('Test ');
|
||||||
|
|
||||||
|
// The autocomplete still shouldn't be visible
|
||||||
|
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-controls')!)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Type an at sign to trigger the user autocomplete
|
||||||
|
await userEvent.keyboard('@');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure that the input is correctly linked to the suggestion list
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-controls')!)).toBe(screen.getByRole('listbox'));
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toBe(screen.getByRole('listbox').firstElementChild);
|
||||||
|
|
||||||
|
// The number of results should also be read out
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('2 suggestions available');
|
||||||
|
|
||||||
|
// Pressing the down arrow should change the selection to the second user
|
||||||
|
await userEvent.keyboard('{arrowdown}');
|
||||||
|
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toBe(screen.getByRole('listbox').lastElementChild);
|
||||||
|
|
||||||
|
// Pressing the up arrow should change the selection back to the first user
|
||||||
|
await userEvent.keyboard('{arrowup}');
|
||||||
|
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toBe(screen.getByRole('listbox').firstElementChild);
|
||||||
|
|
||||||
|
// Pressing enter should complete the result and close the suggestions
|
||||||
|
await userEvent.keyboard('{enter}');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('Test @apple ');
|
||||||
|
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(document.getElementById(input.getAttribute('aria-controls')!)).not.toBeInTheDocument();
|
||||||
|
expect(input).not.toHaveAttribute('aria-activedescendant');
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {shallow} from 'enzyme';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import SuggestionList from 'components/suggestion/suggestion_list';
|
|
||||||
|
|
||||||
describe('components/SuggestionList', () => {
|
|
||||||
const baseProps = {
|
|
||||||
open: true,
|
|
||||||
onCompleteWord: jest.fn(),
|
|
||||||
pretext: '',
|
|
||||||
cleared: false,
|
|
||||||
matchedPretext: [],
|
|
||||||
items: [],
|
|
||||||
terms: [],
|
|
||||||
selection: '',
|
|
||||||
components: [],
|
|
||||||
onItemHover: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
test('should not throw error when currentLabel is null and label is generated', () => {
|
|
||||||
const wrapper = shallow<SuggestionList>(
|
|
||||||
<SuggestionList
|
|
||||||
{...baseProps}
|
|
||||||
ariaLiveRef={React.createRef()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const instance = wrapper.instance();
|
|
||||||
instance.currentLabel = null;
|
|
||||||
|
|
||||||
instance.generateLabel({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,16 +3,13 @@
|
|||||||
|
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import {FormattedMessage} from 'react-intl';
|
|
||||||
|
|
||||||
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
||||||
|
|
||||||
import {Constants} from 'utils/constants';
|
import {Constants} from 'utils/constants';
|
||||||
import {isEmptyObject} from 'utils/utils';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
ariaLiveRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
inputRef?: React.RefObject<HTMLDivElement>;
|
inputRef?: React.RefObject<HTMLDivElement>;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
position?: 'top' | 'bottom';
|
position?: 'top' | 'bottom';
|
||||||
@@ -46,9 +43,7 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
contentRef: React.RefObject<HTMLUListElement>;
|
contentRef: React.RefObject<HTMLUListElement>;
|
||||||
wrapperRef: React.RefObject<HTMLDivElement>;
|
wrapperRef: React.RefObject<HTMLDivElement>;
|
||||||
itemRefs: Map<string, any>;
|
itemRefs: Map<string, HTMLElement>;
|
||||||
currentLabel: string | null;
|
|
||||||
currentItem: any;
|
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@@ -57,8 +52,6 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
this.contentRef = React.createRef();
|
this.contentRef = React.createRef();
|
||||||
this.wrapperRef = React.createRef();
|
this.wrapperRef = React.createRef();
|
||||||
this.itemRefs = new Map();
|
this.itemRefs = new Map();
|
||||||
this.currentLabel = '';
|
|
||||||
this.currentItem = {};
|
|
||||||
this.maxHeight = 0;
|
this.maxHeight = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,19 +64,11 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
this.scrollToItem(this.props.selection);
|
this.scrollToItem(this.props.selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEmptyObject(this.currentItem)) {
|
|
||||||
this.generateLabel(this.currentItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.items.length > 0 && prevProps.items.length === 0) {
|
if (this.props.items.length > 0 && prevProps.items.length === 0) {
|
||||||
this.updateMaxHeight();
|
this.updateMaxHeight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.removeLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMaxHeight = () => {
|
updateMaxHeight = () => {
|
||||||
if (!this.props.inputRef?.current) {
|
if (!this.props.inputRef?.current) {
|
||||||
return;
|
return;
|
||||||
@@ -101,42 +86,6 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
announceLabel() {
|
|
||||||
const suggestionReadOut = this.props.ariaLiveRef?.current;
|
|
||||||
if (suggestionReadOut) {
|
|
||||||
suggestionReadOut.textContent = this.currentLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLabel() {
|
|
||||||
const suggestionReadOut = this.props.ariaLiveRef?.current;
|
|
||||||
if (suggestionReadOut) {
|
|
||||||
suggestionReadOut.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateLabel(item: any) {
|
|
||||||
if (item.username) {
|
|
||||||
this.currentLabel = item.username;
|
|
||||||
if ((item.first_name || item.last_name) && item.nickname) {
|
|
||||||
this.currentLabel += ` ${item.first_name} ${item.last_name} ${item.nickname}`;
|
|
||||||
} else if (item.nickname) {
|
|
||||||
this.currentLabel += ` ${item.nickname}`;
|
|
||||||
} else if (item.first_name || item.last_name) {
|
|
||||||
this.currentLabel += ` ${item.first_name} ${item.last_name}`;
|
|
||||||
}
|
|
||||||
} else if (item.type === 'mention.channels') {
|
|
||||||
this.currentLabel = item.channel.display_name;
|
|
||||||
} else if (item.emoji) {
|
|
||||||
this.currentLabel = item.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentLabel) {
|
|
||||||
this.currentLabel = this.currentLabel.toLowerCase();
|
|
||||||
}
|
|
||||||
this.announceLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
getContent = () => {
|
getContent = () => {
|
||||||
return this.contentRef.current;
|
return this.contentRef.current;
|
||||||
};
|
};
|
||||||
@@ -155,7 +104,7 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
const contentTopPadding = this.getComputedCssProperty(content, 'paddingTop');
|
const contentTopPadding = this.getComputedCssProperty(content, 'paddingTop');
|
||||||
const contentBottomPadding = this.getComputedCssProperty(content, 'paddingTop');
|
const contentBottomPadding = this.getComputedCssProperty(content, 'paddingTop');
|
||||||
|
|
||||||
const item = ReactDOM.findDOMNode(this.itemRefs.get(term));
|
const item = this.itemRefs.get(term);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -272,14 +221,11 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelection) {
|
|
||||||
this.currentItem = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
<Component
|
<Component
|
||||||
key={term}
|
key={term}
|
||||||
ref={(ref: any) => this.itemRefs.set(term, ref)}
|
ref={(ref: any) => this.itemRefs.set(term, ref)}
|
||||||
|
id={`suggestionList_item_${term}`}
|
||||||
item={this.props.items[i]}
|
item={this.props.items[i]}
|
||||||
term={term}
|
term={term}
|
||||||
matchedPretext={this.props.matchedPretext[i]}
|
matchedPretext={this.props.matchedPretext[i]}
|
||||||
@@ -297,7 +243,7 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
ref={this.wrapperRef}
|
ref={this.wrapperRef}
|
||||||
className={mainClass}
|
className={mainClass}
|
||||||
>
|
>
|
||||||
<ul
|
<SuggestionListList
|
||||||
id='suggestionList'
|
id='suggestionList'
|
||||||
data-testid='suggestionList'
|
data-testid='suggestionList'
|
||||||
role='listbox'
|
role='listbox'
|
||||||
@@ -310,8 +256,46 @@ export default class SuggestionList extends React.PureComponent<Props> {
|
|||||||
onMouseDown={this.props.preventClose}
|
onMouseDown={this.props.preventClose}
|
||||||
>
|
>
|
||||||
{items}
|
{items}
|
||||||
</ul>
|
</SuggestionListList>
|
||||||
|
<SuggestionListStatus items={this.props.items}/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SuggestionListList = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>((props, ref) => {
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
aria-label={formatMessage({id: 'suggestionList.label', defaultMessage: 'Suggestions'})}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function SuggestionListStatus({items}: Pick<Props, 'items'>) {
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
const statusText = formatMessage(
|
||||||
|
{
|
||||||
|
id: 'suggestionList.suggestionsAvailable',
|
||||||
|
defaultMessage: '{count, number} {count, plural, one {suggestion} other {suggestions}} available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: items.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='sr-only'
|
||||||
|
aria-atomic={true}
|
||||||
|
aria-live='polite'
|
||||||
|
role='status'
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -256,23 +256,12 @@ const SwitchChannelSuggestion = React.forwardRef<HTMLLIElement, Props>((props, r
|
|||||||
}
|
}
|
||||||
const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS;
|
const showSlug = (isPartOfOnlyOneTeam || channel.type === Constants.DM_CHANNEL) && channel.type !== Constants.THREADS;
|
||||||
|
|
||||||
const getId = () => {
|
|
||||||
if (channel.type === Constants.DM_CHANNEL) {
|
|
||||||
if (prefix) {
|
|
||||||
return `quickSwitchInput_${(channel as FakeDirectChannel).userId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `quickSwitchInput_${channel.id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SuggestionContainer
|
<SuggestionContainer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid={channel.name}
|
data-testid={channel.name}
|
||||||
role='option'
|
|
||||||
aria-labelledby={`${name.toLowerCase().replaceAll(' ', '-')}-item-name`}
|
aria-labelledby={`${name.toLowerCase().replaceAll(' ', '-')}-item-name`}
|
||||||
{...props}
|
{...props}
|
||||||
id={getId()}
|
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className='suggestion-list__ellipsis suggestion-list__flex'>
|
<div className='suggestion-list__ellipsis suggestion-list__flex'>
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export default class Textbox extends React.PureComponent<Props> {
|
|||||||
|
|
||||||
this.suggestionProviders.push(
|
this.suggestionProviders.push(
|
||||||
new AtMentionProvider({
|
new AtMentionProvider({
|
||||||
textboxId: this.props.id,
|
|
||||||
currentUserId: this.props.currentUserId,
|
currentUserId: this.props.currentUserId,
|
||||||
channelId: this.props.channelId,
|
channelId: this.props.channelId,
|
||||||
autocompleteUsersInChannel: (prefix: string) => this.props.actions.autocompleteUsersInChannel(prefix, this.props.channelId),
|
autocompleteUsersInChannel: (prefix: string) => this.props.actions.autocompleteUsersInChannel(prefix, this.props.channelId),
|
||||||
@@ -148,7 +147,6 @@ export default class Textbox extends React.PureComponent<Props> {
|
|||||||
for (const provider of this.suggestionProviders) {
|
for (const provider of this.suggestionProviders) {
|
||||||
if (provider instanceof AtMentionProvider) {
|
if (provider instanceof AtMentionProvider) {
|
||||||
provider.setProps({
|
provider.setProps({
|
||||||
textboxId: this.props.id,
|
|
||||||
currentUserId: this.props.currentUserId,
|
currentUserId: this.props.currentUserId,
|
||||||
channelId: this.props.channelId,
|
channelId: this.props.channelId,
|
||||||
autocompleteUsersInChannel: (prefix: string) => this.props.actions.autocompleteUsersInChannel(prefix, this.props.channelId),
|
autocompleteUsersInChannel: (prefix: string) => this.props.actions.autocompleteUsersInChannel(prefix, this.props.channelId),
|
||||||
|
|||||||
@@ -5528,7 +5528,9 @@
|
|||||||
"suggestion.search.private": "Private Channels",
|
"suggestion.search.private": "Private Channels",
|
||||||
"suggestion.search.public": "Public Channels",
|
"suggestion.search.public": "Public Channels",
|
||||||
"suggestion.user.isCurrent": "(you)",
|
"suggestion.user.isCurrent": "(you)",
|
||||||
|
"suggestionList.label": "Suggestions",
|
||||||
"suggestionList.noMatches": "No items match <b>{value}</b>",
|
"suggestionList.noMatches": "No items match <b>{value}</b>",
|
||||||
|
"suggestionList.suggestionsAvailable": "{count, number} {count, plural, one {suggestion} other {suggestions}} available",
|
||||||
"system_notice.adminVisible": "Only visible to System Admins",
|
"system_notice.adminVisible": "Only visible to System Admins",
|
||||||
"system_notice.adminVisible.icon": "Only visible to System Admins Icon",
|
"system_notice.adminVisible.icon": "Only visible to System Admins Icon",
|
||||||
"system_notice.body.api3": "If you’ve created or installed integrations in the last two years, find out how <link>recent changes</link> may have affected them.",
|
"system_notice.body.api3": "If you’ve created or installed integrations in the last two years, find out how <link>recent changes</link> may have affected them.",
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user