From 9e47f2ef0c8050367b62d9da77a2a117b6d488c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Thu, 20 Feb 2025 13:22:05 -0500 Subject: [PATCH] Mm 62677 - modal focus management - find channels modal (#29957) * MM-62312 - modal focus management; revamp quick switch channel modal! * get quick switch test working * configure the generic modal to accept refs to focus within and onhide to the origin element * apply pr feedback, get modal element get autofocus, use id instead of ref * update more direct channels modal to use generic modal * fix unit tests and snapshots * fix unit tests * fix modal margin top to fit in smaller screens * fix e2e test * remove unnecesary onexited extra call * fix e2e tests * set correct label * fix snapshots * create helper function for sending custom focus event * migrate quick switch modal to use new approach to focus * migrate more direct channels modal to new approach * fix snapshots * fix types * fix modal closing behavior * fix snapshots * fix cypress tests * remove only --------- Co-authored-by: Mattermost Build --- .../archive_channel_header_spec.ts | 4 +- .../in_teams_and_channels_spec.ts | 2 +- ...ve_and_archive_channel_destructive_spec.ts | 4 +- .../guest_identification_ui_spec.ts | 4 +- .../permissions/team_permissions_spec.ts | 4 +- .../channels/messaging/focus_move_spec.js | 2 +- .../cypress/tests/support/ui/channel.d.ts | 9 + e2e-tests/cypress/tests/support/ui/channel.js | 13 ++ .../channel_header_dropdown.test.tsx.snap | 6 +- .../channel_header_dropdown_items.tsx | 4 +- .../channel_info_rhs/channel_info_rhs.tsx | 2 +- .../channel_info_rhs/top_buttons.tsx | 3 + .../channel_members_rhs.tsx | 2 +- .../more_direct_channels.test.tsx.snap | 150 +++----------- .../more_direct_channels.scss | 7 + .../more_direct_channels.test.tsx | 1 + .../more_direct_channels.tsx | 115 +++++------ .../src/components/new_search/new_search.tsx | 19 +- .../quick_switch_modal.test.tsx.snap | 183 ++++++++---------- .../quick_switch_modal.test.tsx | 73 +++---- .../quick_switch_modal/quick_switch_modal.tsx | 164 ++++++++-------- .../__snapshots__/sidebar.test.tsx.snap | 7 +- .../channel_navigator.test.tsx | 1 + .../channel_navigator/channel_navigator.tsx | 14 +- .../src/components/sidebar/sidebar.tsx | 1 + .../sidebar_category.test.tsx.snap | 7 +- .../sidebar_category/sidebar_category.tsx | 5 +- .../sidebar_right/sidebar_right.tsx | 25 +-- .../three_days_left_trial_modal.scss | 2 +- .../three_days_left_trial_modal.tsx | 2 +- .../sass/components/_channel-switcher.scss | 10 +- webapp/channels/src/utils/a11y_controller.ts | 17 +- webapp/channels/src/utils/a11y_utils.ts | 59 ++++++ .../src/generic_modal/generic_modal.scss | 2 +- .../src/generic_modal/generic_modal.tsx | 22 ++- 35 files changed, 450 insertions(+), 495 deletions(-) create mode 100644 webapp/channels/src/components/more_direct_channels/more_direct_channels.scss create mode 100644 webapp/channels/src/utils/a11y_utils.ts diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_header_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_header_spec.ts index 15522e858fb..998379a8a6a 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_header_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/archive_channel_header_spec.ts @@ -64,7 +64,7 @@ describe('Archive channel header spec', () => { cy.get('#channelArchiveChannel').should('be.visible'); // * Add members menu option should be visible; - cy.get('#channelAddMembers').should('be.visible'); + cy.get('#channelInviteMembers').should('be.visible'); // * Notification preferences option should be visible; cy.get('#channelNotificationPreferences').should('be.visible'); @@ -91,7 +91,7 @@ describe('Archive channel header spec', () => { cy.get('#channelArchiveChannel').should('not.exist'); // * Add members menu option should not be visible; - cy.get('#channelAddMembers').should('not.exist'); + cy.get('#channelInviteMembers').should('not.exist'); // * Notification preferences option should not be visible; cy.get('#channelNotificationPreferences').should('not.exist'); diff --git a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts index eb5d8781bcd..2004d4daaa2 100644 --- a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts @@ -55,7 +55,7 @@ describe('Managing bots in Teams and Channels', () => { await client.addToTeam(team.id, bot.user_id); // # Add bot to channel in team - cy.uiAddUsersToCurrentChannel([bot.username]); + cy.uiInviteUsersToCurrentChannel([bot.username]); // * Verify system message in-channel cy.uiWaitUntilMessagePostedIncludes(`@${bot.username} added to the channel by you.`); diff --git a/e2e-tests/cypress/tests/integration/channels/channel/leave_and_archive_channel_destructive_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/leave_and_archive_channel_destructive_spec.ts index d809c6b47e6..97f981bec2b 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/leave_and_archive_channel_destructive_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/leave_and_archive_channel_destructive_spec.ts @@ -51,8 +51,8 @@ describe('Leave and Archive channel actions display as destructive', () => { // * Mute Channel menu option should be visible cy.get('#channelToggleMuteChannel').should('be.visible'); - // * Add Members menu option should be visible - cy.get('#channelAddMembers').should('be.visible'); + // * Invite Members menu option should be visible + cy.get('#channelInviteMembers').should('be.visible'); // * Manage Members menu option should be visible cy.get('#channelManageMembers').should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts index ed27395f449..592143e0783 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts @@ -142,7 +142,9 @@ describe('Verify Guest User Identification in different screens', () => { }); // # Close Dialog - cy.get('#quickSwitchModalLabel > .close').click(); + cy.get('#quickSwitchModal').within(() => { + cy.get('button.close[aria-label="Close"]').click(); + }); }); it('MM-T1377 Verify Guest Badge in DM Search dialog', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts index a1b0160fe38..5d807a56750 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts @@ -168,8 +168,8 @@ describe('Team Permissions', () => { // * Verify dropdown opens cy.get('#channelHeaderDropdownMenu .Menu__content.dropdown-menu').should('be.visible'); - // # Click on `Add Members` - cy.get('#channelAddMembers').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); + // # Click on `Invite Members` + cy.get('#channelInviteMembers').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); // # Search and select otherUser cy.get('#selectItems input').typeWithForce(otherUser.username).wait(TIMEOUTS.HALF_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/focus_move_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/focus_move_spec.js index bf70a82cc91..1c032d80922 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/focus_move_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/focus_move_spec.js @@ -142,7 +142,7 @@ function verifyFocusInAddChannelMemberModal() { cy.get('#channelLeaveChannel').should('be.visible'); // # Click 'Add Members' - cy.get('#channelAddMembers').click(); + cy.get('#channelInviteMembers').click(); // * Assert that modal appears cy.get('#addUsersToChannelModal').should('be.visible'); diff --git a/e2e-tests/cypress/tests/support/ui/channel.d.ts b/e2e-tests/cypress/tests/support/ui/channel.d.ts index 6fe8344f38b..a3af8f47a45 100644 --- a/e2e-tests/cypress/tests/support/ui/channel.d.ts +++ b/e2e-tests/cypress/tests/support/ui/channel.d.ts @@ -40,6 +40,15 @@ declare namespace Cypress { */ uiAddUsersToCurrentChannel(usernameList: string[]); + /** + * Invite users to the current channel. + * @param {string[]} usernameList - list of userids to be invited to the channel + * + * @example + * cy.uiInviteUsersToCurrentChannel(['user1', 'user2']); + */ + uiInviteUsersToCurrentChannel(usernameList: string[]); + /** * Archive the current channel. * diff --git a/e2e-tests/cypress/tests/support/ui/channel.js b/e2e-tests/cypress/tests/support/ui/channel.js index 43c1e87c3eb..635c0acfe66 100644 --- a/e2e-tests/cypress/tests/support/ui/channel.js +++ b/e2e-tests/cypress/tests/support/ui/channel.js @@ -54,6 +54,19 @@ Cypress.Commands.add('uiAddUsersToCurrentChannel', (usernameList) => { } }); +Cypress.Commands.add('uiInviteUsersToCurrentChannel', (usernameList) => { + if (usernameList.length) { + cy.get('#channelHeaderDropdownIcon').click(); + cy.get('#channelInviteMembers').click(); + cy.get('#addUsersToChannelModal').should('be.visible'); + usernameList.forEach((username) => { + cy.get('#selectItems input').typeWithForce(`@${username}{enter}`); + }); + cy.get('#saveItems').click(); + cy.get('#addUsersToChannelModal').should('not.exist'); + } +}); + Cypress.Commands.add('uiArchiveChannel', () => { cy.get('#channelHeaderDropdownIcon').click(); cy.get('#channelArchiveChannel').click(); diff --git a/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap b/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap index 78238d26dd5..e068102c41b 100644 --- a/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap +++ b/webapp/channels/src/components/channel_header_dropdown/__snapshots__/channel_header_dropdown.test.tsx.snap @@ -272,7 +272,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with no plugin i "type": [Function], } } - id="channelAddMembers" + id="channelInviteMembers" modalId="channel_invite" show={true} text="Add Members" @@ -280,6 +280,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with no plugin i { permissions={[channelMembersPermission]} > { show={channel.type === Constants.GM_CHANNEL && !isArchived && !isGroupConstrained} modalId={ModalIdentifiers.CREATE_DM_CHANNEL} dialogType={MoreDirectChannels} - dialogProps={{isExistingChannel: true}} + dialogProps={{isExistingChannel: true, focusOriginElement: 'channel_header.menuAriaLabel'}} text={localizeMessage({id: 'navbar.addMembers', defaultMessage: 'Add Members'})} /> diff --git a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx index 22ce139a5c1..2602965f302 100644 --- a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx +++ b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx @@ -105,7 +105,7 @@ const ChannelInfoRhs = ({ return actions.openModal({ modalId: ModalIdentifiers.CREATE_DM_CHANNEL, dialogType: MoreDirectChannels, - dialogProps: {isExistingChannel: true}, + dialogProps: {isExistingChannel: true, focusOriginElement: 'channelInfoRHSAddPeopleButton'}, }); } diff --git a/webapp/channels/src/components/channel_info_rhs/top_buttons.tsx b/webapp/channels/src/components/channel_info_rhs/top_buttons.tsx index 19b5697e61f..62cc9d88ccf 100644 --- a/webapp/channels/src/components/channel_info_rhs/top_buttons.tsx +++ b/webapp/channels/src/components/channel_info_rhs/top_buttons.tsx @@ -135,6 +135,7 @@ export default function TopButtons({ onClick={actions.toggleFavorite} className={isFavorite ? 'active' : ''} aria-label={favoriteText} + id='channelInfoRHSAddFavoriteButton' >
@@ -154,6 +155,7 @@ export default function TopButtons({ onClick={actions.toggleMute} className={isMuted ? 'active' : ''} aria-label={mutedText} + id='channelInfoRHSMuteChannelButton' >
@@ -173,6 +175,7 @@ export default function TopButtons({ - - +
+ `; exports[`components/MoreDirectChannels should match snapshot 1`] = ` - } onEntered={[Function]} onExited={[Function]} onHide={[Function]} - renderBackdrop={[Function]} - restoreFocus={true} - role="none" show={true} > - - - - - - - - - - - +
+ `; diff --git a/webapp/channels/src/components/more_direct_channels/more_direct_channels.scss b/webapp/channels/src/components/more_direct_channels/more_direct_channels.scss new file mode 100644 index 00000000000..26e04b5c6fb --- /dev/null +++ b/webapp/channels/src/components/more_direct_channels/more_direct_channels.scss @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + + +.more-direct-channels-generic-modal { + margin-top: 5vh !important; +} diff --git a/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx b/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx index 13abb786514..0865b516099 100644 --- a/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx +++ b/webapp/channels/src/components/more_direct_channels/more_direct_channels.test.tsx @@ -16,6 +16,7 @@ const mockedUser = TestHelper.getUserMock(); describe('components/MoreDirectChannels', () => { const baseProps: ComponentProps = { + focusOriginElement: 'anyId', currentUserId: 'current_user_id', currentTeamId: 'team_id', currentTeamName: 'team_name', diff --git a/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx b/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx index 950b4b9a1b7..b33ad1dbcf4 100644 --- a/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx +++ b/webapp/channels/src/components/more_direct_channels/more_direct_channels.tsx @@ -3,9 +3,9 @@ import debounce from 'lodash/debounce'; import React from 'react'; -import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {Channel} from '@mattermost/types/channels'; import type {UserProfile} from '@mattermost/types/users'; @@ -13,17 +13,15 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import type MultiSelect from 'components/multiselect/multiselect'; +import {focusElement} from 'utils/a11y_utils'; import {getHistory} from 'utils/browser_history'; import Constants from 'utils/constants'; import List from './list'; import {USERS_PER_PAGE} from './list/list'; -import { - isGroupChannel, - optionValue, -} from './types'; -import type { - OptionValue} from './types'; +import {isGroupChannel, optionValue} from './types'; +import type {OptionValue} from './types'; +import './more_direct_channels.scss'; export type Props = { currentUserId: string; @@ -50,8 +48,8 @@ export type Props = { onModalDismissed?: () => void; onExited?: () => void; actions: { - getProfiles: (page?: number | undefined, perPage?: number | undefined, options?: any) => Promise; - getProfilesInTeam: (teamId: string, page: number, perPage?: number | undefined, sort?: string | undefined, options?: any) => Promise; + getProfiles: (page?: number, perPage?: number, options?: any) => Promise; + getProfilesInTeam: (teamId: string, page: number, perPage?: number, sort?: string, options?: any) => Promise; loadProfilesMissingStatus: (users: UserProfile[]) => void; getTotalUsersStats: () => void; loadStatusesForProfilesList: (users: UserProfile[]) => void; @@ -62,6 +60,7 @@ export type Props = { searchGroupChannels: (term: string) => Promise>; setModalSearchTerm: (term: string) => void; }; + focusOriginElement: string; } type State = { @@ -77,6 +76,7 @@ export default class MoreDirectChannels extends React.PureComponent>; selectedItemRef: React.RefObject; + constructor(props: Props) { super(props); @@ -85,15 +85,12 @@ export default class MoreDirectChannels extends React.PureComponent { + this.setState({loadingUsers: loadingState}); + }; + handleHide = () => { this.props.actions.setModalSearchTerm(''); this.setState({show: false}); }; - setUsersLoadingState = (loadingState: boolean) => { - this.setState({ - loadingUsers: loadingState, - }); - }; - handleExit = () => { + this.props.onExited?.(); + this.props.onModalDismissed?.(); + if (this.exitToChannel) { getHistory().push(this.exitToChannel); + } else { + setTimeout(() => { + focusElement(this.props.focusOriginElement, true); + }, 0); } - - this.props.onModalDismissed?.(); - this.props.onExited?.(); }; handleSubmit = (values = this.state.values) => { const {actions} = this.props; + if (this.state.saving) { return; } @@ -209,26 +207,22 @@ export default class MoreDirectChannels extends React.PureComponent { - const values: OptionValue[] = Object.assign([], this.state.values); + const values = [...this.state.values]; const existingUserIds = values.map((user) => user.id); for (const user of users) { - if (existingUserIds.indexOf(user.id) !== -1) { - continue; + if (!existingUserIds.includes(user.id)) { + values.push(optionValue(user)); } - values.push(optionValue(user)); } - this.setState({values}); }; @@ -284,46 +278,29 @@ export default class MoreDirectChannels extends React.PureComponent ); + const modalHeaderText = ( + + ); + return ( - - - - - - - +
{body} - - - - - +
+ ); } } diff --git a/webapp/channels/src/components/new_search/new_search.tsx b/webapp/channels/src/components/new_search/new_search.tsx index 6562a074728..0b70f469a77 100644 --- a/webapp/channels/src/components/new_search/new_search.tsx +++ b/webapp/channels/src/components/new_search/new_search.tsx @@ -19,8 +19,8 @@ import {getSearchTeam, getSearchTerms, getSearchType} from 'selectors/rhs'; import Popover from 'components/widgets/popover'; import a11yController from 'utils/a11y_controller_instance'; -import type {A11yFocusEventDetail} from 'utils/constants'; -import Constants, {A11yCustomEventTypes} from 'utils/constants'; +import {focusElement} from 'utils/a11y_utils'; +import Constants from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; @@ -185,18 +185,9 @@ const NewSearch = (): JSX.Element => { const closeSearchBox = useCallback(() => { setFocused(false); setCurrentChannel(''); - if (searchButtonRef.current) { - document.dispatchEvent( - new CustomEvent(A11yCustomEventTypes.FOCUS, { - detail: { - target: searchButtonRef.current, - keyboardOnly: false, - }, - }), - ); - a11yController.resetOriginElement(); - } - }, []); + + focusElement(searchButtonRef, true, true); + }, [searchButtonRef, setFocused, setCurrentChannel]); const openSearchBox = useCallback(() => { setFocused(true); diff --git a/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap b/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap index 0754ce1572f..43c23b8a53b 100644 --- a/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap +++ b/webapp/channels/src/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.tsx.snap @@ -1,123 +1,98 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/QuickSwitchModal should match snapshot 1`] = ` - - + id="quickSwitchModal" + keyboardEscape={true} + modalHeaderText={
-

- -

-
- -
+
-
- + } + modalSubheaderText={
- -
-
-
+ } + onExited={[Function]} + onHide={[Function]} + show={true} +> +
+ + +
+ `; diff --git a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.test.tsx b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.test.tsx index 39d47a357da..ab23002ba10 100644 --- a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.test.tsx +++ b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.test.tsx @@ -1,17 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow} from 'enzyme'; import React from 'react'; +import {IntlProvider} from 'react-intl'; +import type {QuickSwitchModal as QuickSwitchModalClass} from 'components/quick_switch_modal/quick_switch_modal'; import QuickSwitchModal from 'components/quick_switch_modal/quick_switch_modal'; import ChannelNavigator from 'components/sidebar/channel_navigator/channel_navigator'; +import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import Constants from 'utils/constants'; describe('components/QuickSwitchModal', () => { const baseProps = { + focusOriginElement: 'anyId', onExited: jest.fn(), showTeamSwitcher: false, isMobileView: false, @@ -28,36 +31,32 @@ describe('components/QuickSwitchModal', () => { }; it('should match snapshot', () => { - const wrapper = shallow( - , - ); - + const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); describe('handleSubmit', () => { it('should do nothing if nothing selected', () => { const props = {...baseProps}; + const wrapper = shallowWithIntl(); + const instance = wrapper.instance() as QuickSwitchModalClass; - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(); - expect(baseProps.onExited).not.toBeCalled(); + instance.handleSubmit(); + expect(props.onExited).not.toBeCalled(); expect(props.actions.switchToChannel).not.toBeCalled(); }); it('should fail to switch to a channel', (done) => { - const wrapper = shallow( - , - ); + const props = {...baseProps}; + const wrapper = shallowWithIntl(); + const instance = wrapper.instance() as QuickSwitchModalClass; const channel = {id: 'channel_id', userId: 'user_id', type: Constants.DM_CHANNEL}; - wrapper.instance().handleSubmit({channel}); - expect(baseProps.actions.switchToChannel).toBeCalledWith(channel); + instance.handleSubmit({channel}); + expect(props.actions.switchToChannel).toBeCalledWith(channel); + process.nextTick(() => { - expect(baseProps.onExited).not.toBeCalled(); + expect(props.onExited).not.toBeCalled(); done(); }); }); @@ -74,15 +73,15 @@ describe('components/QuickSwitchModal', () => { }, }; - const wrapper = shallow( - , - ); + const wrapper = shallowWithIntl(); + const instance = wrapper.instance() as QuickSwitchModalClass; const channel = {id: 'channel_id', userId: 'user_id', type: Constants.DM_CHANNEL}; - wrapper.instance().handleSubmit({channel}); + instance.handleSubmit({channel}); expect(props.actions.switchToChannel).toBeCalledWith(channel); + process.nextTick(() => { - expect(baseProps.onExited).toBeCalled(); + expect(props.onExited).toBeCalled(); done(); }); }); @@ -99,17 +98,18 @@ describe('components/QuickSwitchModal', () => { }, }; - const wrapper = shallow( - , - ); + const wrapper = shallowWithIntl(); + const instance = wrapper.instance() as QuickSwitchModalClass; const channel = {id: 'channel_id', name: 'test', type: Constants.OPEN_CHANNEL}; const selected = { type: Constants.MENTION_MORE_CHANNELS, channel, }; - wrapper.instance().handleSubmit(selected); + + instance.handleSubmit(selected); expect(props.actions.joinChannelById).toBeCalledWith(channel.id); + process.nextTick(() => { expect(props.actions.switchToChannel).toBeCalledWith(channel); done(); @@ -128,20 +128,21 @@ describe('components/QuickSwitchModal', () => { }, }; - const wrapper = shallow( - , - ); + const wrapper = shallowWithIntl(); + const instance = wrapper.instance() as QuickSwitchModalClass; const channel = {id: 'channel_id', name: 'test', type: Constants.DM_CHANNEL}; const selected = { type: Constants.MENTION_MORE_CHANNELS, channel, }; - wrapper.instance().handleSubmit(selected); + + instance.handleSubmit(selected); expect(props.actions.joinChannelById).not.toHaveBeenCalled(); expect(props.actions.switchToChannel).toBeCalledWith(channel); + process.nextTick(() => { - expect(baseProps.onExited).toBeCalled(); + expect(props.onExited).toBeCalled(); done(); }); }); @@ -159,10 +160,12 @@ describe('components/QuickSwitchModal', () => { }; renderWithContext( - <> - - - , + + <> + + + + , ); userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton')); diff --git a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx index d9ee86acaf7..6136e7a75db 100644 --- a/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx +++ b/webapp/channels/src/components/quick_switch_modal/quick_switch_modal.tsx @@ -2,9 +2,10 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; +import {FormattedMessage, injectIntl} from 'react-intl'; +import type {WrappedComponentProps} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {Channel} from '@mattermost/types/channels'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -16,6 +17,7 @@ import type SuggestionBoxComponent from 'components/suggestion/suggestion_box/su import SuggestionList from 'components/suggestion/suggestion_list'; import SwitchChannelProvider from 'components/suggestion/switch_channel_provider'; +import {focusElement} from 'utils/a11y_utils'; import {getHistory} from 'utils/browser_history'; import Constants, {RHSStates} from 'utils/constants'; import * as UserAgent from 'utils/user_agent'; @@ -30,13 +32,9 @@ type ProviderSuggestions = { terms: string[]; items: any[]; component: React.ReactNode; -} +}; -export type Props = { - - /** - * The function called to immediately hide the modal - */ +export type Props = WrappedComponentProps & { onExited: () => void; isMobileView: boolean; @@ -48,25 +46,25 @@ export type Props = { switchToChannel: (channel: Channel) => Promise; closeRightHandSide: () => void; }; -} + focusOriginElement: string; +}; type State = { text: string; - mode: string|null; + mode: string | null; hasSuggestions: boolean; shouldShowLoadingSpinner: boolean; pretext: string; -} +}; -export default class QuickSwitchModal extends React.PureComponent { +export class QuickSwitchModal extends React.PureComponent { private channelProviders: SwitchChannelProvider[]; - private switchBox: SuggestionBoxComponent|null; + private switchBox: SuggestionBoxComponent | null; constructor(props: Props) { super(props); this.channelProviders = [new SwitchChannelProvider()]; - this.switchBox = null; this.state = { @@ -82,7 +80,6 @@ export default class QuickSwitchModal extends React.PureComponent if (this.switchBox === null) { return; } - const textbox = this.switchBox.getTextbox(); if (document.activeElement !== textbox) { textbox.focus(); @@ -116,12 +113,7 @@ export default class QuickSwitchModal extends React.PureComponent private hideOnCancel = () => { this.props.onExited?.(); - setTimeout(() => { - const modalButton = document.querySelector('.SidebarChannelNavigator_jumpToButton') as HTMLElement; - if (modalButton) { - modalButton.focus(); - } - }); + focusElement(this.props.focusOriginElement, true); }; private onChange = (e: React.ChangeEvent): void => { @@ -168,15 +160,15 @@ export default class QuickSwitchModal extends React.PureComponent const providers: SwitchChannelProvider[] = this.channelProviders; const header = ( -

+

-

+ ); - let help; + let help: React.ReactNode; if (this.props.isMobileView) { help = ( ); } - return ( - + {header} + + ); + + const modalSubheaderText = ( +
- -
- {header} -
- {help} -
-
-
- -
- - - {!this.state.shouldShowLoadingSpinner && !this.state.hasSuggestions && this.state.text && + {help} +
+ ); + + return ( + +
+ + + { + !this.state.shouldShowLoadingSpinner && + !this.state.hasSuggestions && + this.state.text && + ( - } -
-
- + ) + } +
+ ); }; } + +export default injectIntl(QuickSwitchModal); diff --git a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap index 99d837490bf..ec1e2eb5d9c 100644 --- a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap @@ -26,7 +26,7 @@ exports[`components/sidebar should match snapshot 1`] = ` id="lhsNavigator" role="application" > - +
- +
@@ -114,7 +115,7 @@ exports[`components/sidebar should match snapshot when more channels modal is op id="lhsNavigator" role="application" > - +
{ props = { showUnreadsCategory: true, isQuickSwitcherOpen: false, + intl: {} as any, actions: { openModal: jest.fn(), closeModal: jest.fn(), diff --git a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx index 2aa007689d6..8cf4c0b4ede 100644 --- a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx +++ b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage} from 'react-intl'; +import {FormattedMessage, injectIntl} from 'react-intl'; +import type {WrappedComponentProps} from 'react-intl'; import {trackEvent} from 'actions/telemetry_actions'; @@ -17,7 +18,7 @@ import type {ModalData} from 'types/actions'; import ChannelFilter from '../channel_filter'; -export type Props = { +export type Props = WrappedComponentProps & { showUnreadsCategory: boolean; isQuickSwitcherOpen: boolean; actions: { @@ -26,7 +27,7 @@ export type Props = { }; }; -export default class ChannelNavigator extends React.PureComponent { +class ChannelNavigator extends React.PureComponent { componentDidMount() { document.addEventListener('keydown', this.handleShortcut); document.addEventListener('keydown', this.handleQuickSwitchKeyPress); @@ -45,6 +46,7 @@ export default class ChannelNavigator extends React.PureComponent { this.props.actions.openModal({ modalId: ModalIdentifiers.QUICK_SWITCH, dialogType: QuickSwitchModal, + dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'}, }); }; @@ -81,6 +83,7 @@ export default class ChannelNavigator extends React.PureComponent { openModal({ modalId: ModalIdentifiers.QUICK_SWITCH, dialogType: QuickSwitchModal, + dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'}, }); } }; @@ -92,9 +95,10 @@ export default class ChannelNavigator extends React.PureComponent {
diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx index 4f4d7502185..9f37203286b 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx @@ -189,7 +189,6 @@ export default class SidebarCategory extends React.PureComponent { draggable='false' className={'SidebarChannel noFloat newChannelSpacer'} {...provided.draggableProps} - role='listitem' tabIndex={-1} /> ); @@ -254,7 +253,7 @@ export default class SidebarCategory extends React.PureComponent { let categoryMenu: JSX.Element; let newLabel: JSX.Element; - let directMessagesModalButton: JSX.Element; + const directMessagesModalButton: JSX.Element | null = null; let isCollapsible = true; if (isNewCategory) { newLabel = ( @@ -289,6 +288,7 @@ export default class SidebarCategory extends React.PureComponent { } >