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 <build@mattermost.com>
Этот коммит содержится в:
Pablo Vélez
2025-02-20 13:22:05 -05:00
коммит произвёл GitHub
родитель fd356b62b4
Коммит 9e47f2ef0c
35 изменённых файлов: 450 добавлений и 495 удалений

Просмотреть файл

@@ -64,7 +64,7 @@ describe('Archive channel header spec', () => {
cy.get('#channelArchiveChannel').should('be.visible'); cy.get('#channelArchiveChannel').should('be.visible');
// * Add members menu option 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; // * Notification preferences option should be visible;
cy.get('#channelNotificationPreferences').should('be.visible'); cy.get('#channelNotificationPreferences').should('be.visible');
@@ -91,7 +91,7 @@ describe('Archive channel header spec', () => {
cy.get('#channelArchiveChannel').should('not.exist'); cy.get('#channelArchiveChannel').should('not.exist');
// * Add members menu option should not be visible; // * 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; // * Notification preferences option should not be visible;
cy.get('#channelNotificationPreferences').should('not.exist'); cy.get('#channelNotificationPreferences').should('not.exist');

Просмотреть файл

@@ -55,7 +55,7 @@ describe('Managing bots in Teams and Channels', () => {
await client.addToTeam(team.id, bot.user_id); await client.addToTeam(team.id, bot.user_id);
// # Add bot to channel in team // # Add bot to channel in team
cy.uiAddUsersToCurrentChannel([bot.username]); cy.uiInviteUsersToCurrentChannel([bot.username]);
// * Verify system message in-channel // * Verify system message in-channel
cy.uiWaitUntilMessagePostedIncludes(`@${bot.username} added to the channel by you.`); cy.uiWaitUntilMessagePostedIncludes(`@${bot.username} added to the channel by you.`);

Просмотреть файл

@@ -51,8 +51,8 @@ describe('Leave and Archive channel actions display as destructive', () => {
// * Mute Channel menu option should be visible // * Mute Channel menu option should be visible
cy.get('#channelToggleMuteChannel').should('be.visible'); cy.get('#channelToggleMuteChannel').should('be.visible');
// * Add Members menu option should be visible // * Invite Members menu option should be visible
cy.get('#channelAddMembers').should('be.visible'); cy.get('#channelInviteMembers').should('be.visible');
// * Manage Members menu option should be visible // * Manage Members menu option should be visible
cy.get('#channelManageMembers').should('be.visible'); cy.get('#channelManageMembers').should('be.visible');

Просмотреть файл

@@ -142,7 +142,9 @@ describe('Verify Guest User Identification in different screens', () => {
}); });
// # Close Dialog // # 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', () => { it('MM-T1377 Verify Guest Badge in DM Search dialog', () => {

Просмотреть файл

@@ -168,8 +168,8 @@ describe('Team Permissions', () => {
// * Verify dropdown opens // * Verify dropdown opens
cy.get('#channelHeaderDropdownMenu .Menu__content.dropdown-menu').should('be.visible'); cy.get('#channelHeaderDropdownMenu .Menu__content.dropdown-menu').should('be.visible');
// # Click on `Add Members` // # Click on `Invite Members`
cy.get('#channelAddMembers').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); cy.get('#channelInviteMembers').should('be.visible').click().wait(TIMEOUTS.HALF_SEC);
// # Search and select otherUser // # Search and select otherUser
cy.get('#selectItems input').typeWithForce(otherUser.username).wait(TIMEOUTS.HALF_SEC); cy.get('#selectItems input').typeWithForce(otherUser.username).wait(TIMEOUTS.HALF_SEC);

Просмотреть файл

@@ -142,7 +142,7 @@ function verifyFocusInAddChannelMemberModal() {
cy.get('#channelLeaveChannel').should('be.visible'); cy.get('#channelLeaveChannel').should('be.visible');
// # Click 'Add Members' // # Click 'Add Members'
cy.get('#channelAddMembers').click(); cy.get('#channelInviteMembers').click();
// * Assert that modal appears // * Assert that modal appears
cy.get('#addUsersToChannelModal').should('be.visible'); cy.get('#addUsersToChannelModal').should('be.visible');

Просмотреть файл

@@ -40,6 +40,15 @@ declare namespace Cypress {
*/ */
uiAddUsersToCurrentChannel(usernameList: string[]); 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. * Archive the current channel.
* *

Просмотреть файл

@@ -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', () => { Cypress.Commands.add('uiArchiveChannel', () => {
cy.get('#channelHeaderDropdownIcon').click(); cy.get('#channelHeaderDropdownIcon').click();
cy.get('#channelArchiveChannel').click(); cy.get('#channelArchiveChannel').click();

Просмотреть файл

@@ -272,7 +272,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with no plugin i
"type": [Function], "type": [Function],
} }
} }
id="channelAddMembers" id="channelInviteMembers"
modalId="channel_invite" modalId="channel_invite"
show={true} show={true}
text="Add Members" text="Add Members"
@@ -280,6 +280,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with no plugin i
<MenuItemToggleModalRedux <MenuItemToggleModalRedux
dialogProps={ dialogProps={
Object { Object {
"focusOriginElement": "channel_header.menuAriaLabel",
"isExistingChannel": true, "isExistingChannel": true,
} }
} }
@@ -1104,7 +1105,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with plugins 1`]
"type": [Function], "type": [Function],
} }
} }
id="channelAddMembers" id="channelInviteMembers"
modalId="channel_invite" modalId="channel_invite"
show={true} show={true}
text="Add Members" text="Add Members"
@@ -1112,6 +1113,7 @@ exports[`components/ChannelHeaderDropdown should match snapshot with plugins 1`]
<MenuItemToggleModalRedux <MenuItemToggleModalRedux
dialogProps={ dialogProps={
Object { Object {
"focusOriginElement": "channel_header.menuAriaLabel",
"isExistingChannel": true, "isExistingChannel": true,
} }
} }

Просмотреть файл

@@ -155,7 +155,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent<Props> {
permissions={[channelMembersPermission]} permissions={[channelMembersPermission]}
> >
<Menu.ItemToggleModalRedux <Menu.ItemToggleModalRedux
id='channelAddMembers' id='channelInviteMembers'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && !isGroupConstrained} show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && !isGroupConstrained}
modalId={ModalIdentifiers.CHANNEL_INVITE} modalId={ModalIdentifiers.CHANNEL_INVITE}
dialogType={ChannelInviteModal} dialogType={ChannelInviteModal}
@@ -167,7 +167,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent<Props> {
show={channel.type === Constants.GM_CHANNEL && !isArchived && !isGroupConstrained} show={channel.type === Constants.GM_CHANNEL && !isArchived && !isGroupConstrained}
modalId={ModalIdentifiers.CREATE_DM_CHANNEL} modalId={ModalIdentifiers.CREATE_DM_CHANNEL}
dialogType={MoreDirectChannels} dialogType={MoreDirectChannels}
dialogProps={{isExistingChannel: true}} dialogProps={{isExistingChannel: true, focusOriginElement: 'channel_header.menuAriaLabel'}}
text={localizeMessage({id: 'navbar.addMembers', defaultMessage: 'Add Members'})} text={localizeMessage({id: 'navbar.addMembers', defaultMessage: 'Add Members'})}
/> />
</ChannelPermissionGate> </ChannelPermissionGate>

Просмотреть файл

@@ -105,7 +105,7 @@ const ChannelInfoRhs = ({
return actions.openModal({ return actions.openModal({
modalId: ModalIdentifiers.CREATE_DM_CHANNEL, modalId: ModalIdentifiers.CREATE_DM_CHANNEL,
dialogType: MoreDirectChannels, dialogType: MoreDirectChannels,
dialogProps: {isExistingChannel: true}, dialogProps: {isExistingChannel: true, focusOriginElement: 'channelInfoRHSAddPeopleButton'},
}); });
} }

Просмотреть файл

@@ -135,6 +135,7 @@ export default function TopButtons({
onClick={actions.toggleFavorite} onClick={actions.toggleFavorite}
className={isFavorite ? 'active' : ''} className={isFavorite ? 'active' : ''}
aria-label={favoriteText} aria-label={favoriteText}
id='channelInfoRHSAddFavoriteButton'
> >
<div> <div>
<i className={'icon ' + favoriteIcon}/> <i className={'icon ' + favoriteIcon}/>
@@ -154,6 +155,7 @@ export default function TopButtons({
onClick={actions.toggleMute} onClick={actions.toggleMute}
className={isMuted ? 'active' : ''} className={isMuted ? 'active' : ''}
aria-label={mutedText} aria-label={mutedText}
id='channelInfoRHSMuteChannelButton'
> >
<div> <div>
<i className={'icon ' + mutedIcon}/> <i className={'icon ' + mutedIcon}/>
@@ -173,6 +175,7 @@ export default function TopButtons({
<Button <Button
onClick={actions.addPeople} onClick={actions.addPeople}
className={isInvitingPeople ? 'active' : ''} className={isInvitingPeople ? 'active' : ''}
id='channelInfoRHSAddPeopleButton'
> >
<div> <div>
<i className='icon icon-account-plus-outline'/> <i className='icon icon-account-plus-outline'/>

Просмотреть файл

@@ -192,7 +192,7 @@ export default function ChannelMembersRHS({
return actions.openModal({ return actions.openModal({
modalId: ModalIdentifiers.CREATE_DM_CHANNEL, modalId: ModalIdentifiers.CREATE_DM_CHANNEL,
dialogType: MoreDirectChannels, dialogType: MoreDirectChannels,
dialogProps: {isExistingChannel: true}, dialogProps: {isExistingChannel: true, focusOriginElement: 'channelInfoRHSAddPeopleButton'},
}); });
} }

Просмотреть файл

@@ -1,56 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/MoreDirectChannels should exclude deleted users if there is not direct channel between users 1`] = ` exports[`components/MoreDirectChannels should exclude deleted users if there is not direct channel between users 1`] = `
<Modal <GenericModal
animation={true} autoCloseOnCancelButton={true}
aria-labelledby="moreDmModalLabel" autoCloseOnConfirmButton={true}
autoFocus={true} bodyPadding={false}
backdrop={true} className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
bsClass="modal" compassDesign={true}
dialogClassName="a11y__modal more-modal more-direct-channels"
dialogComponentClass={[Function]}
enforceFocus={true} enforceFocus={true}
id="moreDmModal" id="moreDmModal"
keyboard={true} keyboardEscape={true}
manager={ modalHeaderText={
ModalManager { <Memo(MemoizedFormattedMessage)
"add": [Function], defaultMessage="Direct Messages"
"containers": Array [], id="more_direct_channels.title"
"data": Array [], />
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
} }
onEntered={[Function]} onEntered={[Function]}
onExited={[Function]} onExited={[Function]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
> >
<ModalHeader <div
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="moreDmModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Direct Messages"
id="more_direct_channels.title"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
role="application" role="application"
> >
<Connect(Component) <Connect(Component)
@@ -271,77 +242,32 @@ exports[`components/MoreDirectChannels should exclude deleted users if there is
} }
values={Array []} values={Array []}
/> />
</ModalBody> </div>
<ModalFooter </GenericModal>
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`; `;
exports[`components/MoreDirectChannels should match snapshot 1`] = ` exports[`components/MoreDirectChannels should match snapshot 1`] = `
<Modal <GenericModal
animation={true} autoCloseOnCancelButton={true}
aria-labelledby="moreDmModalLabel" autoCloseOnConfirmButton={true}
autoFocus={true} bodyPadding={false}
backdrop={true} className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
bsClass="modal" compassDesign={true}
dialogClassName="a11y__modal more-modal more-direct-channels"
dialogComponentClass={[Function]}
enforceFocus={true} enforceFocus={true}
id="moreDmModal" id="moreDmModal"
keyboard={true} keyboardEscape={true}
manager={ modalHeaderText={
ModalManager { <Memo(MemoizedFormattedMessage)
"add": [Function], defaultMessage="Direct Messages"
"containers": Array [], id="more_direct_channels.title"
"data": Array [], />
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
} }
onEntered={[Function]} onEntered={[Function]}
onExited={[Function]} onExited={[Function]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true} show={true}
> >
<ModalHeader <div
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="moreDmModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Direct Messages"
id="more_direct_channels.title"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
role="application" role="application"
> >
<Connect(Component) <Connect(Component)
@@ -569,22 +495,6 @@ exports[`components/MoreDirectChannels should match snapshot 1`] = `
] ]
} }
/> />
</ModalBody> </div>
<ModalFooter </GenericModal>
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`; `;

Просмотреть файл

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

Просмотреть файл

@@ -16,6 +16,7 @@ const mockedUser = TestHelper.getUserMock();
describe('components/MoreDirectChannels', () => { describe('components/MoreDirectChannels', () => {
const baseProps: ComponentProps<typeof MoreDirectChannels> = { const baseProps: ComponentProps<typeof MoreDirectChannels> = {
focusOriginElement: 'anyId',
currentUserId: 'current_user_id', currentUserId: 'current_user_id',
currentTeamId: 'team_id', currentTeamId: 'team_id',
currentTeamName: 'team_name', currentTeamName: 'team_name',

Просмотреть файл

@@ -3,9 +3,9 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import React from 'react'; import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels'; import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users'; 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 type MultiSelect from 'components/multiselect/multiselect';
import {focusElement} from 'utils/a11y_utils';
import {getHistory} from 'utils/browser_history'; import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import List from './list'; import List from './list';
import {USERS_PER_PAGE} from './list/list'; import {USERS_PER_PAGE} from './list/list';
import { import {isGroupChannel, optionValue} from './types';
isGroupChannel, import type {OptionValue} from './types';
optionValue, import './more_direct_channels.scss';
} from './types';
import type {
OptionValue} from './types';
export type Props = { export type Props = {
currentUserId: string; currentUserId: string;
@@ -50,8 +48,8 @@ export type Props = {
onModalDismissed?: () => void; onModalDismissed?: () => void;
onExited?: () => void; onExited?: () => void;
actions: { actions: {
getProfiles: (page?: number | undefined, perPage?: number | undefined, options?: any) => Promise<ActionResult>; getProfiles: (page?: number, perPage?: number, options?: any) => Promise<ActionResult>;
getProfilesInTeam: (teamId: string, page: number, perPage?: number | undefined, sort?: string | undefined, options?: any) => Promise<ActionResult>; getProfilesInTeam: (teamId: string, page: number, perPage?: number, sort?: string, options?: any) => Promise<ActionResult>;
loadProfilesMissingStatus: (users: UserProfile[]) => void; loadProfilesMissingStatus: (users: UserProfile[]) => void;
getTotalUsersStats: () => void; getTotalUsersStats: () => void;
loadStatusesForProfilesList: (users: UserProfile[]) => void; loadStatusesForProfilesList: (users: UserProfile[]) => void;
@@ -62,6 +60,7 @@ export type Props = {
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>; searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
setModalSearchTerm: (term: string) => void; setModalSearchTerm: (term: string) => void;
}; };
focusOriginElement: string;
} }
type State = { type State = {
@@ -77,6 +76,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
exitToChannel?: string; exitToChannel?: string;
multiselect: React.RefObject<MultiSelect<OptionValue>>; multiselect: React.RefObject<MultiSelect<OptionValue>>;
selectedItemRef: React.RefObject<HTMLDivElement>; selectedItemRef: React.RefObject<HTMLDivElement>;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@@ -85,15 +85,12 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.selectedItemRef = React.createRef(); this.selectedItemRef = React.createRef();
const values: OptionValue[] = []; const values: OptionValue[] = [];
if (props.currentChannelMembers) { if (props.currentChannelMembers) {
for (let i = 0; i < props.currentChannelMembers.length; i++) { for (let i = 0; i < props.currentChannelMembers.length; i++) {
const user = Object.assign({}, props.currentChannelMembers[i]); const user = Object.assign({}, props.currentChannelMembers[i]);
if (user.id === props.currentUserId) { if (user.id === props.currentUserId) {
continue; continue;
} }
values.push(optionValue(user)); values.push(optionValue(user));
} }
} }
@@ -144,9 +141,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
} }
} }
if ( if (prevProps.users.length !== this.props.users.length) {
prevProps.users.length !== this.props.users.length
) {
this.props.actions.loadProfilesMissingStatus(this.props.users); this.props.actions.loadProfilesMissingStatus(this.props.users);
} }
} }
@@ -155,28 +150,31 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.updateFromProps(prevProps); this.updateFromProps(prevProps);
} }
setUsersLoadingState = (loadingState: boolean) => {
this.setState({loadingUsers: loadingState});
};
handleHide = () => { handleHide = () => {
this.props.actions.setModalSearchTerm(''); this.props.actions.setModalSearchTerm('');
this.setState({show: false}); this.setState({show: false});
}; };
setUsersLoadingState = (loadingState: boolean) => {
this.setState({
loadingUsers: loadingState,
});
};
handleExit = () => { handleExit = () => {
this.props.onExited?.();
this.props.onModalDismissed?.();
if (this.exitToChannel) { if (this.exitToChannel) {
getHistory().push(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) => { handleSubmit = (values = this.state.values) => {
const {actions} = this.props; const {actions} = this.props;
if (this.state.saving) { if (this.state.saving) {
return; return;
} }
@@ -209,26 +207,22 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
if (isGroupChannel(value)) { if (isGroupChannel(value)) {
this.addUsers(value.profiles); this.addUsers(value.profiles);
} else { } else {
const values = Object.assign([], this.state.values); const values = [...this.state.values];
if (!values.includes(value)) {
if (values.indexOf(value) === -1) {
values.push(value); values.push(value);
} }
this.setState({values}); this.setState({values});
} }
}; };
addUsers = (users: UserProfile[]) => { addUsers = (users: UserProfile[]) => {
const values: OptionValue[] = Object.assign([], this.state.values); const values = [...this.state.values];
const existingUserIds = values.map((user) => user.id); const existingUserIds = values.map((user) => user.id);
for (const user of users) { for (const user of users) {
if (existingUserIds.indexOf(user.id) !== -1) { if (!existingUserIds.includes(user.id)) {
continue; values.push(optionValue(user));
} }
values.push(optionValue(user));
} }
this.setState({values}); this.setState({values});
}; };
@@ -284,46 +278,29 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
/> />
); );
const modalHeaderText = (
<FormattedMessage
id='more_direct_channels.title'
defaultMessage='Direct Messages'
/>
);
return ( return (
<Modal <GenericModal
dialogClassName='a11y__modal more-modal more-direct-channels'
show={this.state.show}
onHide={this.handleHide}
onExited={this.handleExit}
onEntered={this.loadModalData}
role='none'
aria-labelledby='moreDmModalLabel'
id='moreDmModal' id='moreDmModal'
className='a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal'
show={this.state.show}
modalHeaderText={modalHeaderText}
onExited={this.handleExit}
onHide={this.handleExit}
compassDesign={true}
bodyPadding={false}
onEntered={this.loadModalData}
> >
<Modal.Header closeButton={true}> <div role='application'>
<Modal.Title
componentClass='h1'
id='moreDmModalLabel'
>
<FormattedMessage
id='more_direct_channels.title'
defaultMessage='Direct Messages'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body
role='application'
>
{body} {body}
</Modal.Body> </div>
<Modal.Footer className='modal-footer--invisible'> </GenericModal>
<button
id='closeModalButton'
type='button'
className='btn btn-tertiary'
>
<FormattedMessage
id='general_button.close'
defaultMessage='Close'
/>
</button>
</Modal.Footer>
</Modal>
); );
} }
} }

Просмотреть файл

@@ -19,8 +19,8 @@ import {getSearchTeam, getSearchTerms, getSearchType} from 'selectors/rhs';
import Popover from 'components/widgets/popover'; import Popover from 'components/widgets/popover';
import a11yController from 'utils/a11y_controller_instance'; import a11yController from 'utils/a11y_controller_instance';
import type {A11yFocusEventDetail} from 'utils/constants'; import {focusElement} from 'utils/a11y_utils';
import Constants, {A11yCustomEventTypes} from 'utils/constants'; import Constants from 'utils/constants';
import * as Keyboard from 'utils/keyboard'; import * as Keyboard from 'utils/keyboard';
import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version';
import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent';
@@ -185,18 +185,9 @@ const NewSearch = (): JSX.Element => {
const closeSearchBox = useCallback(() => { const closeSearchBox = useCallback(() => {
setFocused(false); setFocused(false);
setCurrentChannel(''); setCurrentChannel('');
if (searchButtonRef.current) {
document.dispatchEvent( focusElement(searchButtonRef, true, true);
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, { }, [searchButtonRef, setFocused, setCurrentChannel]);
detail: {
target: searchButtonRef.current,
keyboardOnly: false,
},
}),
);
a11yController.resetOriginElement();
}
}, []);
const openSearchBox = useCallback(() => { const openSearchBox = useCallback(() => {
setFocused(true); setFocused(true);

Просмотреть файл

@@ -1,123 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/QuickSwitchModal should match snapshot 1`] = ` exports[`components/QuickSwitchModal should match snapshot 1`] = `
<Modal <GenericModal
animation={false} ariaLabel="Find Channels"
aria-describedby="quickSwitchHeaderWithHint" autoCloseOnCancelButton={true}
aria-labelledby="quickSwitchHeader" autoCloseOnConfirmButton={true}
autoFocus={true} bodyPadding={false}
backdrop={true} className="a11y__modal channel-switcher"
bsClass="modal" compassDesign={true}
dialogClassName="a11y__modal channel-switcher"
dialogComponentClass={[Function]}
enforceFocus={false} enforceFocus={false}
keyboard={true} id="quickSwitchModal"
manager={ keyboardEscape={true}
ModalManager { modalHeaderText={
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={false}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
className="modal-header"
closeButton={true}
closeLabel="Close"
id="quickSwitchModalLabel"
>
<div <div
className="channel-switcher__header" className="channel-switcher__header"
id="quickSwitchHeaderWithHint"
> >
<h1 <h2
id="quickSwitchHeader" id="quickSwitchHeader"
> >
<MemoizedFormattedMessage <Memo(MemoizedFormattedMessage)
defaultMessage="Find Channels" defaultMessage="Find Channels"
id="quick_switch_modal.switchChannels" id="quick_switch_modal.switchChannels"
/> />
</h1> </h2>
<div
className="channel-switcher__hint"
id="quickSwitchHint"
>
<MemoizedFormattedMessage
defaultMessage="Type to find a channel. Use <b>UP/DOWN</b> to browse, <b>ENTER</b> to select, <b>ESC</b> to dismiss."
id="quickSwitchModal.help_no_team"
values={
Object {
"b": [Function],
}
}
/>
</div>
</div> </div>
</ModalHeader> }
<ModalBody modalSubheaderText={
bsClass="modal-body"
componentClass="div"
>
<div <div
className="channel-switcher__suggestion-box" className="channel-switcher__hint"
id="quickSwitchHint"
> >
<i <Memo(MemoizedFormattedMessage)
className="icon icon-magnify icon-16" defaultMessage="Type to find a channel. Use <b>UP/DOWN</b> to browse, <b>ENTER</b> to select, <b>ESC</b> to dismiss."
/> id="quickSwitchModal.help_no_team"
<Connect(SuggestionBox) values={
aria-label="quick switch input" Object {
className="form-control focused" "b": [Function],
completeOnTab={false} }
delayInputUpdate={true}
forceSuggestionsWhenBlur={true}
id="quickSwitchInput"
listComponent={[Function]}
listPosition="bottom"
maxLength="64"
onChange={[Function]}
onItemSelected={[Function]}
onSuggestionsReceived={[Function]}
openWhenEmpty={true}
providers={
Array [
SwitchChannelProvider {
"disableDispatches": false,
"forceDispatch": false,
"latestComplete": true,
"latestPrefix": "",
"requestStarted": false,
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
},
]
} }
renderDividers={
Array [
"mention.unread",
"mention.recent.channels",
]
}
shouldSearchCompleteText={true}
spellCheck="false"
value=""
/> />
</div> </div>
</ModalBody> }
</Modal> onExited={[Function]}
onHide={[Function]}
show={true}
>
<div
className="channel-switcher__suggestion-box"
>
<i
className="icon icon-magnify icon-16"
/>
<Connect(SuggestionBox)
aria-label="quick switch input"
className="form-control focused"
completeOnTab={false}
delayInputUpdate={true}
forceSuggestionsWhenBlur={true}
id="quickSwitchInput"
listComponent={[Function]}
listPosition="bottom"
maxLength="64"
onChange={[Function]}
onItemSelected={[Function]}
onSuggestionsReceived={[Function]}
openWhenEmpty={true}
providers={
Array [
SwitchChannelProvider {
"disableDispatches": false,
"forceDispatch": false,
"latestComplete": true,
"latestPrefix": "",
"requestStarted": false,
"store": Object {
"@@observable": [Function],
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
},
]
}
renderDividers={
Array [
"mention.unread",
"mention.recent.channels",
]
}
shouldSearchCompleteText={true}
spellCheck="false"
value=""
/>
</div>
</GenericModal>
`; `;

Просмотреть файл

@@ -1,17 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react'; 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 QuickSwitchModal from 'components/quick_switch_modal/quick_switch_modal';
import ChannelNavigator from 'components/sidebar/channel_navigator/channel_navigator'; 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 {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
describe('components/QuickSwitchModal', () => { describe('components/QuickSwitchModal', () => {
const baseProps = { const baseProps = {
focusOriginElement: 'anyId',
onExited: jest.fn(), onExited: jest.fn(),
showTeamSwitcher: false, showTeamSwitcher: false,
isMobileView: false, isMobileView: false,
@@ -28,36 +31,32 @@ describe('components/QuickSwitchModal', () => {
}; };
it('should match snapshot', () => { it('should match snapshot', () => {
const wrapper = shallow( const wrapper = shallowWithIntl(<QuickSwitchModal {...baseProps}/>);
<QuickSwitchModal {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
describe('handleSubmit', () => { describe('handleSubmit', () => {
it('should do nothing if nothing selected', () => { it('should do nothing if nothing selected', () => {
const props = {...baseProps}; const props = {...baseProps};
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
const instance = wrapper.instance() as QuickSwitchModalClass;
const wrapper = shallow<QuickSwitchModal>( instance.handleSubmit();
<QuickSwitchModal {...props}/>, expect(props.onExited).not.toBeCalled();
);
wrapper.instance().handleSubmit();
expect(baseProps.onExited).not.toBeCalled();
expect(props.actions.switchToChannel).not.toBeCalled(); expect(props.actions.switchToChannel).not.toBeCalled();
}); });
it('should fail to switch to a channel', (done) => { it('should fail to switch to a channel', (done) => {
const wrapper = shallow<QuickSwitchModal>( const props = {...baseProps};
<QuickSwitchModal {...baseProps}/>, const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
); const instance = wrapper.instance() as QuickSwitchModalClass;
const channel = {id: 'channel_id', userId: 'user_id', type: Constants.DM_CHANNEL}; const channel = {id: 'channel_id', userId: 'user_id', type: Constants.DM_CHANNEL};
wrapper.instance().handleSubmit({channel}); instance.handleSubmit({channel});
expect(baseProps.actions.switchToChannel).toBeCalledWith(channel); expect(props.actions.switchToChannel).toBeCalledWith(channel);
process.nextTick(() => { process.nextTick(() => {
expect(baseProps.onExited).not.toBeCalled(); expect(props.onExited).not.toBeCalled();
done(); done();
}); });
}); });
@@ -74,15 +73,15 @@ describe('components/QuickSwitchModal', () => {
}, },
}; };
const wrapper = shallow<QuickSwitchModal>( const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
<QuickSwitchModal {...props}/>, const instance = wrapper.instance() as QuickSwitchModalClass;
);
const channel = {id: 'channel_id', userId: 'user_id', type: Constants.DM_CHANNEL}; 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); expect(props.actions.switchToChannel).toBeCalledWith(channel);
process.nextTick(() => { process.nextTick(() => {
expect(baseProps.onExited).toBeCalled(); expect(props.onExited).toBeCalled();
done(); done();
}); });
}); });
@@ -99,17 +98,18 @@ describe('components/QuickSwitchModal', () => {
}, },
}; };
const wrapper = shallow<QuickSwitchModal>( const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
<QuickSwitchModal {...props}/>, const instance = wrapper.instance() as QuickSwitchModalClass;
);
const channel = {id: 'channel_id', name: 'test', type: Constants.OPEN_CHANNEL}; const channel = {id: 'channel_id', name: 'test', type: Constants.OPEN_CHANNEL};
const selected = { const selected = {
type: Constants.MENTION_MORE_CHANNELS, type: Constants.MENTION_MORE_CHANNELS,
channel, channel,
}; };
wrapper.instance().handleSubmit(selected);
instance.handleSubmit(selected);
expect(props.actions.joinChannelById).toBeCalledWith(channel.id); expect(props.actions.joinChannelById).toBeCalledWith(channel.id);
process.nextTick(() => { process.nextTick(() => {
expect(props.actions.switchToChannel).toBeCalledWith(channel); expect(props.actions.switchToChannel).toBeCalledWith(channel);
done(); done();
@@ -128,20 +128,21 @@ describe('components/QuickSwitchModal', () => {
}, },
}; };
const wrapper = shallow<QuickSwitchModal>( const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
<QuickSwitchModal {...props}/>, const instance = wrapper.instance() as QuickSwitchModalClass;
);
const channel = {id: 'channel_id', name: 'test', type: Constants.DM_CHANNEL}; const channel = {id: 'channel_id', name: 'test', type: Constants.DM_CHANNEL};
const selected = { const selected = {
type: Constants.MENTION_MORE_CHANNELS, type: Constants.MENTION_MORE_CHANNELS,
channel, channel,
}; };
wrapper.instance().handleSubmit(selected);
instance.handleSubmit(selected);
expect(props.actions.joinChannelById).not.toHaveBeenCalled(); expect(props.actions.joinChannelById).not.toHaveBeenCalled();
expect(props.actions.switchToChannel).toBeCalledWith(channel); expect(props.actions.switchToChannel).toBeCalledWith(channel);
process.nextTick(() => { process.nextTick(() => {
expect(baseProps.onExited).toBeCalled(); expect(props.onExited).toBeCalled();
done(); done();
}); });
}); });
@@ -159,10 +160,12 @@ describe('components/QuickSwitchModal', () => {
}; };
renderWithContext( renderWithContext(
<> <IntlProvider locale='en'>
<ChannelNavigator {...channelNavigatorProps}/> <>
<QuickSwitchModal {...baseProps}/> <ChannelNavigator {...channelNavigatorProps}/>
</>, <QuickSwitchModal {...baseProps}/>
</>
</IntlProvider>,
); );
userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton')); userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton'));

Просмотреть файл

@@ -2,9 +2,10 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {Modal} from 'react-bootstrap'; import {FormattedMessage, injectIntl} from 'react-intl';
import {FormattedMessage} from 'react-intl'; import type {WrappedComponentProps} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels'; import type {Channel} from '@mattermost/types/channels';
import type {ActionResult} from 'mattermost-redux/types/actions'; 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 SuggestionList from 'components/suggestion/suggestion_list';
import SwitchChannelProvider from 'components/suggestion/switch_channel_provider'; import SwitchChannelProvider from 'components/suggestion/switch_channel_provider';
import {focusElement} from 'utils/a11y_utils';
import {getHistory} from 'utils/browser_history'; import {getHistory} from 'utils/browser_history';
import Constants, {RHSStates} from 'utils/constants'; import Constants, {RHSStates} from 'utils/constants';
import * as UserAgent from 'utils/user_agent'; import * as UserAgent from 'utils/user_agent';
@@ -30,13 +32,9 @@ type ProviderSuggestions = {
terms: string[]; terms: string[];
items: any[]; items: any[];
component: React.ReactNode; component: React.ReactNode;
} };
export type Props = { export type Props = WrappedComponentProps & {
/**
* The function called to immediately hide the modal
*/
onExited: () => void; onExited: () => void;
isMobileView: boolean; isMobileView: boolean;
@@ -48,25 +46,25 @@ export type Props = {
switchToChannel: (channel: Channel) => Promise<ActionResult>; switchToChannel: (channel: Channel) => Promise<ActionResult>;
closeRightHandSide: () => void; closeRightHandSide: () => void;
}; };
} focusOriginElement: string;
};
type State = { type State = {
text: string; text: string;
mode: string|null; mode: string | null;
hasSuggestions: boolean; hasSuggestions: boolean;
shouldShowLoadingSpinner: boolean; shouldShowLoadingSpinner: boolean;
pretext: string; pretext: string;
} };
export default class QuickSwitchModal extends React.PureComponent<Props, State> { export class QuickSwitchModal extends React.PureComponent<Props, State> {
private channelProviders: SwitchChannelProvider[]; private channelProviders: SwitchChannelProvider[];
private switchBox: SuggestionBoxComponent|null; private switchBox: SuggestionBoxComponent | null;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.channelProviders = [new SwitchChannelProvider()]; this.channelProviders = [new SwitchChannelProvider()];
this.switchBox = null; this.switchBox = null;
this.state = { this.state = {
@@ -82,7 +80,6 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
if (this.switchBox === null) { if (this.switchBox === null) {
return; return;
} }
const textbox = this.switchBox.getTextbox(); const textbox = this.switchBox.getTextbox();
if (document.activeElement !== textbox) { if (document.activeElement !== textbox) {
textbox.focus(); textbox.focus();
@@ -116,12 +113,7 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
private hideOnCancel = () => { private hideOnCancel = () => {
this.props.onExited?.(); this.props.onExited?.();
setTimeout(() => { focusElement(this.props.focusOriginElement, true);
const modalButton = document.querySelector('.SidebarChannelNavigator_jumpToButton') as HTMLElement;
if (modalButton) {
modalButton.focus();
}
});
}; };
private onChange = (e: React.ChangeEvent<HTMLInputElement>): void => { private onChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
@@ -168,15 +160,15 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
const providers: SwitchChannelProvider[] = this.channelProviders; const providers: SwitchChannelProvider[] = this.channelProviders;
const header = ( const header = (
<h1 id='quickSwitchHeader'> <h2 id='quickSwitchHeader'>
<FormattedMessage <FormattedMessage
id='quick_switch_modal.switchChannels' id='quick_switch_modal.switchChannels'
defaultMessage='Find Channels' defaultMessage='Find Channels'
/> />
</h1> </h2>
); );
let help; let help: React.ReactNode;
if (this.props.isMobileView) { if (this.props.isMobileView) {
help = ( help = (
<FormattedMessage <FormattedMessage
@@ -196,71 +188,75 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
); );
} }
return ( const modalHeaderText = (
<Modal <div className='channel-switcher__header'>
dialogClassName='a11y__modal channel-switcher' {header}
show={true} </div>
onHide={this.hideOnCancel} );
enforceFocus={false}
restoreFocus={false} const modalSubheaderText = (
role='none' <div
aria-labelledby='quickSwitchHeader' className='channel-switcher__hint'
aria-describedby='quickSwitchHeaderWithHint' id='quickSwitchHint'
animation={false}
> >
<Modal.Header {help}
className='modal-header' </div>
id='quickSwitchModalLabel' );
closeButton={true}
> return (
<div <GenericModal
className='channel-switcher__header' className='a11y__modal channel-switcher'
id='quickSwitchHeaderWithHint' id='quickSwitchModal'
> show={true}
{header} bodyPadding={false}
<div enforceFocus={false}
className='channel-switcher__hint' onExited={this.hideOnCancel}
id='quickSwitchHint' onHide={this.hideOnCancel}
> ariaLabel={this.props.intl.formatMessage({id: 'quick_switch_modal.switchChannels', defaultMessage: 'Find Channels'})}
{help} modalHeaderText={modalHeaderText}
</div> modalSubheaderText={modalSubheaderText}
</div> compassDesign={true}
</Modal.Header> >
<Modal.Body> <div className='channel-switcher__suggestion-box'>
<div className='channel-switcher__suggestion-box'> <i className='icon icon-magnify icon-16'/>
<i className='icon icon-magnify icon-16'/> <SuggestionBox
<SuggestionBox // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore
// @ts-ignore ref={this.setSwitchBoxRef}
ref={this.setSwitchBoxRef} id='quickSwitchInput'
id='quickSwitchInput' aria-label={this.props.intl.formatMessage({id: 'quick_switch_modal.input', defaultMessage: 'quick switch input'})}
aria-label={Utils.localizeMessage({id: 'quick_switch_modal.input', defaultMessage: 'quick switch input'})} className='form-control focused'
className='form-control focused' onChange={this.onChange}
onChange={this.onChange} value={this.state.text}
value={this.state.text} onItemSelected={this.handleSubmit}
onItemSelected={this.handleSubmit} listComponent={SuggestionList}
listComponent={SuggestionList} listPosition='bottom'
listPosition='bottom' maxLength='64'
maxLength='64' providers={providers}
providers={providers} completeOnTab={false}
completeOnTab={false} spellCheck='false'
spellCheck='false' delayInputUpdate={true}
delayInputUpdate={true} openWhenEmpty={true}
openWhenEmpty={true} onSuggestionsReceived={this.handleSuggestionsReceived}
onSuggestionsReceived={this.handleSuggestionsReceived} forceSuggestionsWhenBlur={true}
forceSuggestionsWhenBlur={true} renderDividers={[Constants.MENTION_UNREAD, Constants.MENTION_RECENT_CHANNELS]}
renderDividers={[Constants.MENTION_UNREAD, Constants.MENTION_RECENT_CHANNELS]} shouldSearchCompleteText={true}
shouldSearchCompleteText={true} />
/> {
{!this.state.shouldShowLoadingSpinner && !this.state.hasSuggestions && this.state.text && !this.state.shouldShowLoadingSpinner &&
!this.state.hasSuggestions &&
this.state.text &&
(
<NoResultsIndicator <NoResultsIndicator
variant={NoResultsVariant.Search} variant={NoResultsVariant.Search}
titleValues={{channelName: `${this.state.pretext}`}} titleValues={{channelName: `${this.state.pretext}`}}
/> />
} )
</div> }
</Modal.Body> </div>
</Modal> </GenericModal>
); );
}; };
} }
export default injectIntl(QuickSwitchModal);

Просмотреть файл

@@ -26,7 +26,7 @@ exports[`components/sidebar should match snapshot 1`] = `
id="lhsNavigator" id="lhsNavigator"
role="application" role="application"
> >
<Connect(ChannelNavigator) /> <Connect(injectIntl(ChannelNavigator)) />
</div> </div>
<div <div
className="sidebar--left__icons" className="sidebar--left__icons"
@@ -68,7 +68,7 @@ exports[`components/sidebar should match snapshot when direct channels modal is
id="lhsNavigator" id="lhsNavigator"
role="application" role="application"
> >
<Connect(ChannelNavigator) /> <Connect(injectIntl(ChannelNavigator)) />
</div> </div>
<div <div
className="sidebar--left__icons" className="sidebar--left__icons"
@@ -84,6 +84,7 @@ exports[`components/sidebar should match snapshot when direct channels modal is
/> />
<Connect(DataPrefetch) /> <Connect(DataPrefetch) />
<MoreDirectChannels <MoreDirectChannels
focusOriginElement="newDirectMessageButton"
isExistingChannel={false} isExistingChannel={false}
onModalDismissed={[Function]} onModalDismissed={[Function]}
/> />
@@ -114,7 +115,7 @@ exports[`components/sidebar should match snapshot when more channels modal is op
id="lhsNavigator" id="lhsNavigator"
role="application" role="application"
> >
<Connect(ChannelNavigator) /> <Connect(injectIntl(ChannelNavigator)) />
</div> </div>
<div <div
className="sidebar--left__icons" className="sidebar--left__icons"

Просмотреть файл

@@ -16,6 +16,7 @@ describe('Components/ChannelNavigator', () => {
props = { props = {
showUnreadsCategory: true, showUnreadsCategory: true,
isQuickSwitcherOpen: false, isQuickSwitcherOpen: false,
intl: {} as any,
actions: { actions: {
openModal: jest.fn(), openModal: jest.fn(),
closeModal: jest.fn(), closeModal: jest.fn(),

Просмотреть файл

@@ -2,7 +2,8 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; 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'; import {trackEvent} from 'actions/telemetry_actions';
@@ -17,7 +18,7 @@ import type {ModalData} from 'types/actions';
import ChannelFilter from '../channel_filter'; import ChannelFilter from '../channel_filter';
export type Props = { export type Props = WrappedComponentProps & {
showUnreadsCategory: boolean; showUnreadsCategory: boolean;
isQuickSwitcherOpen: boolean; isQuickSwitcherOpen: boolean;
actions: { actions: {
@@ -26,7 +27,7 @@ export type Props = {
}; };
}; };
export default class ChannelNavigator extends React.PureComponent<Props> { class ChannelNavigator extends React.PureComponent<Props> {
componentDidMount() { componentDidMount() {
document.addEventListener('keydown', this.handleShortcut); document.addEventListener('keydown', this.handleShortcut);
document.addEventListener('keydown', this.handleQuickSwitchKeyPress); document.addEventListener('keydown', this.handleQuickSwitchKeyPress);
@@ -45,6 +46,7 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
this.props.actions.openModal({ this.props.actions.openModal({
modalId: ModalIdentifiers.QUICK_SWITCH, modalId: ModalIdentifiers.QUICK_SWITCH,
dialogType: QuickSwitchModal, dialogType: QuickSwitchModal,
dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'},
}); });
}; };
@@ -81,6 +83,7 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
openModal({ openModal({
modalId: ModalIdentifiers.QUICK_SWITCH, modalId: ModalIdentifiers.QUICK_SWITCH,
dialogType: QuickSwitchModal, dialogType: QuickSwitchModal,
dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'},
}); });
} }
}; };
@@ -92,9 +95,10 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
<button <button
className={'SidebarChannelNavigator_jumpToButton'} className={'SidebarChannelNavigator_jumpToButton'}
onClick={this.openQuickSwitcher} onClick={this.openQuickSwitcher}
aria-label={Utils.localizeMessage({id: 'sidebar_left.channel_navigator.channelSwitcherLabel', defaultMessage: 'Channel Switcher'})} aria-label={this.props.intl.formatMessage({id: 'sidebar_left.channel_navigator.channelSwitcherLabel', defaultMessage: 'Channel Switcher'})}
aria-haspopup='dialog' aria-haspopup='dialog'
data-testid='SidebarChannelNavigatorButton' data-testid='SidebarChannelNavigatorButton'
id='SidebarChannelNavigatorButton'
> >
<i className='icon icon-magnify'/> <i className='icon icon-magnify'/>
<FormattedMessage <FormattedMessage
@@ -109,3 +113,5 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
); );
} }
} }
export default injectIntl(ChannelNavigator);

Просмотреть файл

@@ -204,6 +204,7 @@ export default class Sidebar extends React.PureComponent<Props, State> {
<MoreDirectChannels <MoreDirectChannels
onModalDismissed={this.hideMoreDirectChannelsModal} onModalDismissed={this.hideMoreDirectChannelsModal}
isExistingChannel={false} isExistingChannel={false}
focusOriginElement='newDirectMessageButton'
/> />
); );
} }

Просмотреть файл

@@ -56,7 +56,6 @@ exports[`components/sidebar/sidebar_category should match snapshot 2`] = `
> >
<ul <ul
className="NavGroupContent" className="NavGroupContent"
role="list"
> >
<Connect(SidebarChannel) <Connect(SidebarChannel)
channelId="channel_id" channelId="channel_id"
@@ -129,7 +128,6 @@ exports[`components/sidebar/sidebar_category should match snapshot when collapse
> >
<ul <ul
className="NavGroupContent" className="NavGroupContent"
role="list"
> >
<Connect(SidebarChannel) <Connect(SidebarChannel)
channelId="channel_id" channelId="channel_id"
@@ -208,7 +206,6 @@ exports[`components/sidebar/sidebar_category should match snapshot when isNewCat
> >
<ul <ul
className="NavGroupContent" className="NavGroupContent"
role="list"
> >
<PublicDraggable <PublicDraggable
draggableId="NEW_CHANNEL_SPACER__category1" draggableId="NEW_CHANNEL_SPACER__category1"
@@ -323,6 +320,7 @@ exports[`components/sidebar/sidebar_category should match snapshot when sorting
<button <button
aria-label="Create new direct message" aria-label="Create new direct message"
className="SidebarChannelGroupHeader_addButton" className="SidebarChannelGroupHeader_addButton"
id="newDirectMessageButton"
onClick={[Function]} onClick={[Function]}
> >
<i <i
@@ -336,7 +334,6 @@ exports[`components/sidebar/sidebar_category should match snapshot when sorting
> >
<ul <ul
className="NavGroupContent" className="NavGroupContent"
role="list"
> >
<Connect(SidebarChannel) <Connect(SidebarChannel)
channelId="channel_id" channelId="channel_id"
@@ -434,6 +431,7 @@ exports[`components/sidebar/sidebar_category should match snapshot when the cate
<button <button
aria-label="Create new direct message" aria-label="Create new direct message"
className="SidebarChannelGroupHeader_addButton" className="SidebarChannelGroupHeader_addButton"
id="newDirectMessageButton"
onClick={[Function]} onClick={[Function]}
> >
<i <i
@@ -447,7 +445,6 @@ exports[`components/sidebar/sidebar_category should match snapshot when the cate
> >
<ul <ul
className="NavGroupContent" className="NavGroupContent"
role="list"
/> />
</div> </div>
</div> </div>

Просмотреть файл

@@ -189,7 +189,6 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
draggable='false' draggable='false'
className={'SidebarChannel noFloat newChannelSpacer'} className={'SidebarChannel noFloat newChannelSpacer'}
{...provided.draggableProps} {...provided.draggableProps}
role='listitem'
tabIndex={-1} tabIndex={-1}
/> />
); );
@@ -254,7 +253,7 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
let categoryMenu: JSX.Element; let categoryMenu: JSX.Element;
let newLabel: JSX.Element; let newLabel: JSX.Element;
let directMessagesModalButton: JSX.Element; const directMessagesModalButton: JSX.Element | null = null;
let isCollapsible = true; let isCollapsible = true;
if (isNewCategory) { if (isNewCategory) {
newLabel = ( newLabel = (
@@ -289,6 +288,7 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
} }
> >
<button <button
id='newDirectMessageButton'
className='SidebarChannelGroupHeader_addButton' className='SidebarChannelGroupHeader_addButton'
onClick={this.handleOpenDirectMessagesModal} onClick={this.handleOpenDirectMessagesModal}
aria-label={addHelpLabel} aria-label={addHelpLabel}
@@ -380,7 +380,6 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
className={classNames('SidebarChannelGroup_content')} className={classNames('SidebarChannelGroup_content')}
> >
<ul <ul
role='list'
className='NavGroupContent' className='NavGroupContent'
> >
{this.renderNewDropBox(droppableSnapshot.isDraggingOver)} {this.renderNewDropBox(droppableSnapshot.isDraggingOver)}

Просмотреть файл

@@ -23,8 +23,8 @@ import Search from 'components/search/index';
import RhsPlugin from 'plugins/rhs_plugin'; import RhsPlugin from 'plugins/rhs_plugin';
import a11yController from 'utils/a11y_controller_instance'; import a11yController from 'utils/a11y_controller_instance';
import type {A11yFocusEventDetail} from 'utils/constants'; import {focusElement} from 'utils/a11y_utils';
import Constants, {A11yCustomEventTypes} from 'utils/constants'; import Constants from 'utils/constants';
import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard'; import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard';
import {isMac} from 'utils/user_agent'; import {isMac} from 'utils/user_agent';
@@ -159,34 +159,21 @@ export default class SidebarRight extends React.PureComponent<Props, State> {
if (this.props.isOpen && (contentChanged || (!wasOpen && isOpen))) { if (this.props.isOpen && (contentChanged || (!wasOpen && isOpen))) {
this.previousActiveElement = document.activeElement as HTMLElement; this.previousActiveElement = document.activeElement as HTMLElement;
// Focus the sidebar after a tick
setTimeout(() => { setTimeout(() => {
if (this.sidebarRight.current) { if (this.sidebarRight.current) {
document.dispatchEvent( focusElement(this.sidebarRight, false);
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.sidebarRight.current,
keyboardOnly: false,
},
}),
);
} }
}, 0); }, 0);
} else if (!this.props.isOpen && wasOpen) { } else if (!this.props.isOpen && wasOpen) {
// RHS just was closed, restore focus to the previous element had it // RHS just was closed, restore focus to the previous element had it
// this will have to change for upcoming work specially for search and probalby plugins
if (a11yController.originElement) { if (a11yController.originElement) {
a11yController.restoreOriginFocus(); a11yController.restoreOriginFocus();
} else { } else {
setTimeout(() => { setTimeout(() => {
if (this.previousActiveElement) { if (this.previousActiveElement) {
document.dispatchEvent( focusElement(this.previousActiveElement, false);
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.previousActiveElement,
keyboardOnly: false,
},
}),
);
this.previousActiveElement = null; this.previousActiveElement = null;
} }
}, 0); }, 0);

Просмотреть файл

@@ -2,7 +2,7 @@
@use 'utils/mixins'; @use 'utils/mixins';
// since the modal is kind of tall, this makes it look more aligned to the top and the content better distributed // since the modal is kind of tall, this makes it look more aligned to the top and the content better distributed
.GenericModal.modal-dialog { .three-days-left-generic-modal {
margin-top: calc(40vh - 240px) !important; margin-top: calc(40vh - 240px) !important;
} }

Просмотреть файл

@@ -138,7 +138,7 @@ function ThreeDaysLeftTrialModal(props: Props): JSX.Element | null {
return ( return (
<GenericModal <GenericModal
className='ThreeDaysLeftTrialModal' className='ThreeDaysLeftTrialModal three-days-left-generic-modal'
id='threeDaysLeftTrialModal' id='threeDaysLeftTrialModal'
onExited={handleOnClose} onExited={handleOnClose}
modalHeaderText={headerText} modalHeaderText={headerText}

Просмотреть файл

@@ -35,6 +35,11 @@
height: 362px; height: 362px;
padding: 0; padding: 0;
.input-wrapper {
position: inherit !important;
height: 42px;
}
.icon-magnify { .icon-magnify {
position: absolute; position: absolute;
top: 1.1rem; top: 1.1rem;
@@ -51,7 +56,7 @@
height: 40px; height: 40px;
padding: 0 34px; padding: 0 34px;
border-radius: 4px; border-radius: 4px;
margin: 0 32px; margin: 2px 32px;
&:focus { &:focus {
padding: 0 33px; padding: 0 33px;
@@ -173,12 +178,13 @@ body:not(.app__body) {
} }
.channel-switcher__hint { .channel-switcher__hint {
color: rgb(var(--center-channel-color-rgb));
font-size: 12px; font-size: 12px;
} }
.channel-switcher__header, .channel-switcher__header,
.channel-invite__header { .channel-invite__header {
h1 { h2 {
margin: 0 0 0.8rem; margin: 0 0 0.8rem;
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;

Просмотреть файл

@@ -395,14 +395,13 @@ export default class A11yController {
restoreOriginFocus() { restoreOriginFocus() {
if (this.originElement && this.isElementValid(this.originElement)) { if (this.originElement && this.isElementValid(this.originElement)) {
// Dispatch a focus event to manually focus this element // Dispatch a focus event to manually focus this element
document.dispatchEvent( const customEvent = new CustomEvent(A11yCustomEventTypes.FOCUS, {
new CustomEvent(A11yCustomEventTypes.FOCUS, { detail: {
detail: { target: this.originElement,
target: this.originElement, keyboardOnly: false,
keyboardOnly: false, },
}, });
}), this.handleA11yFocus(customEvent);
);
setTimeout(() => { setTimeout(() => {
this.originElement = null; this.originElement = null;
}, 0); }, 0);
@@ -410,7 +409,7 @@ export default class A11yController {
} }
/** /**
* Resets the a11y navigation controller, active region/section/element, clears focus and resets user interraction states * Resets the a11y navigation controller, active region/section/element, clears focus and resets user interaction states
*/ */
cancelNavigation() { cancelNavigation() {
this.clearActiveRegion(); this.clearActiveRegion();

59
webapp/channels/src/utils/a11y_utils.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import a11y from './a11y_controller_instance';
import type {A11yFocusEventDetail} from './constants';
import {A11yCustomEventTypes} from './constants';
/**
* Dispatches an accessibility-focused custom event on the given DOM element,
* ref, or string ID.
*
* - If a string is provided, it uses `document.getElementById(...)`.
* - If a React ref is provided, it uses `ref.current`.
* - If an HTMLElement is provided, it uses that element directly.
*
* @param elementOrId - The DOM element, a ref to it, or a string ID.
* @param keyboardOnly - Whether this focus event is triggered by keyboard interaction. Defaults to `true`.
* @param resetOriginElement - Whether the original element stored data in the a11y controller should be reseted.
*/
export function focusElement(
elementOrId: HTMLElement | React.RefObject<HTMLElement> | string,
keyboardOnly = true,
resetOriginElement = false,
) {
let target: HTMLElement | null = null;
if (typeof elementOrId === 'string') {
// It's an ID string
target = document.getElementById(elementOrId);
} else if (
// It's a React ref object
typeof elementOrId === 'object' &&
'current' in elementOrId &&
elementOrId.current instanceof HTMLElement
) {
target = elementOrId.current;
} else if (elementOrId instanceof HTMLElement) {
// Direct HTMLElement
target = elementOrId;
}
// Dispatch focus event if a valid DOM element is found.
if (target) {
setTimeout(() => {
document.dispatchEvent(
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target,
keyboardOnly,
},
}),
);
if (resetOriginElement) {
a11y.resetOriginElement();
}
}, 0);
}
}

Просмотреть файл

@@ -24,7 +24,7 @@
} }
} }
p#genericModalSubheading { div#genericModalSubheading {
font-size: 12px; font-size: 12px;
margin-block: 10px; margin-block: 10px;
} }

Просмотреть файл

@@ -11,6 +11,8 @@ import './generic_modal.scss';
export type Props = { export type Props = {
className?: string; className?: string;
onExited: () => void; onExited: () => void;
onEntered?: () => void;
onHide?: () => void;
modalHeaderText?: React.ReactNode; modalHeaderText?: React.ReactNode;
modalSubheaderText?: React.ReactNode; modalSubheaderText?: React.ReactNode;
show?: boolean; show?: boolean;
@@ -52,7 +54,6 @@ type State = {
show: boolean; show: boolean;
isFocalTrapActive: boolean; isFocalTrapActive: boolean;
} }
export class GenericModal extends React.PureComponent<Props, State> { export class GenericModal extends React.PureComponent<Props, State> {
static defaultProps: Partial<Props> = { static defaultProps: Partial<Props> = {
show: true, show: true,
@@ -73,8 +74,15 @@ export class GenericModal extends React.PureComponent<Props, State> {
}; };
} }
componentDidUpdate(prevProps: Props) {
if (prevProps.show !== this.props.show) {
this.setState({show: Boolean(this.props.show)});
}
}
onHide = () => { onHide = () => {
this.setState({show: false}); this.setState({show: false});
this.props.onHide?.();
}; };
handleCancel = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { handleCancel = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -102,7 +110,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
if (event.nativeEvent.isComposing) { if (event.nativeEvent.isComposing) {
return; return;
} }
if (this.props.autoCloseOnConfirmButton) { if (this.props.handleConfirm && this.props.autoCloseOnConfirmButton) {
this.onHide(); this.onHide();
} }
if (this.props.handleEnterKeyPress) { if (this.props.handleEnterKeyPress) {
@@ -199,6 +207,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
backdropClassName={this.props.backdropClassName} backdropClassName={this.props.backdropClassName}
container={this.props.container} container={this.props.container}
keyboard={this.props.keyboardEscape} keyboard={this.props.keyboardEscape}
onEntered={this.props.onEntered}
> >
<div <div
onKeyDown={this.onEnterKeyDown} onKeyDown={this.onEnterKeyDown}
@@ -206,23 +215,24 @@ export class GenericModal extends React.PureComponent<Props, State> {
className='GenericModal__wrapper-enter-key-press-catcher' className='GenericModal__wrapper-enter-key-press-catcher'
> >
<Modal.Header closeButton={true}> <Modal.Header closeButton={true}>
<div className='GenericModal__header__text_container'> <div
className='GenericModal__header__text_container'
>
{this.props.compassDesign && ( {this.props.compassDesign && (
<> <>
{headerText} {headerText}
{this.props.headerInput} {this.props.headerInput}
</> </>
)} )}
{ {
this.props.modalSubheaderText && this.props.modalSubheaderText &&
<div className='modal-subheading-container'> <div className='modal-subheading-container'>
<p <div
id='genericModalSubheading' id='genericModalSubheading'
className='modal-subheading' className='modal-subheading'
> >
{this.props.modalSubheaderText} {this.props.modalSubheaderText}
</p> </div>
</div> </div>
} }
</div> </div>