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>
This commit is contained in:
Pablo Vélez 2025-02-20 13:22:05 -05:00 committed by GitHub
parent fd356b62b4
commit 9e47f2ef0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 450 additions and 495 deletions

View file

@ -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');

View file

@ -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.`);

View file

@ -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');

View file

@ -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', () => {

View file

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

View file

@ -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');

View file

@ -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.
*

View file

@ -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();

View file

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

View file

@ -155,7 +155,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent<Props> {
permissions={[channelMembersPermission]}
>
<Menu.ItemToggleModalRedux
id='channelAddMembers'
id='channelInviteMembers'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && !isGroupConstrained}
modalId={ModalIdentifiers.CHANNEL_INVITE}
dialogType={ChannelInviteModal}
@ -167,7 +167,7 @@ export default class ChannelHeaderDropdown extends React.PureComponent<Props> {
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'})}
/>
</ChannelPermissionGate>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ActionResult>;
getProfilesInTeam: (teamId: string, page: number, perPage?: number | undefined, sort?: string | undefined, options?: any) => Promise<ActionResult>;
getProfiles: (page?: number, perPage?: number, options?: any) => Promise<ActionResult>;
getProfilesInTeam: (teamId: string, page: number, perPage?: number, sort?: string, options?: any) => Promise<ActionResult>;
loadProfilesMissingStatus: (users: UserProfile[]) => void;
getTotalUsersStats: () => void;
loadStatusesForProfilesList: (users: UserProfile[]) => void;
@ -62,6 +60,7 @@ export type Props = {
searchGroupChannels: (term: string) => Promise<ActionResult<Channel[]>>;
setModalSearchTerm: (term: string) => void;
};
focusOriginElement: string;
}
type State = {
@ -77,6 +76,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
exitToChannel?: string;
multiselect: React.RefObject<MultiSelect<OptionValue>>;
selectedItemRef: React.RefObject<HTMLDivElement>;
constructor(props: Props) {
super(props);
@ -85,15 +85,12 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.selectedItemRef = React.createRef();
const values: OptionValue[] = [];
if (props.currentChannelMembers) {
for (let i = 0; i < props.currentChannelMembers.length; i++) {
const user = Object.assign({}, props.currentChannelMembers[i]);
if (user.id === props.currentUserId) {
continue;
}
values.push(optionValue(user));
}
}
@ -144,9 +141,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
}
}
if (
prevProps.users.length !== this.props.users.length
) {
if (prevProps.users.length !== this.props.users.length) {
this.props.actions.loadProfilesMissingStatus(this.props.users);
}
}
@ -155,28 +150,31 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
this.updateFromProps(prevProps);
}
setUsersLoadingState = (loadingState: boolean) => {
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<Props, State
if (isGroupChannel(value)) {
this.addUsers(value.profiles);
} else {
const values = Object.assign([], this.state.values);
if (values.indexOf(value) === -1) {
const values = [...this.state.values];
if (!values.includes(value)) {
values.push(value);
}
this.setState({values});
}
};
addUsers = (users: UserProfile[]) => {
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<Props, State
/>
);
const modalHeaderText = (
<FormattedMessage
id='more_direct_channels.title'
defaultMessage='Direct Messages'
/>
);
return (
<Modal
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'
<GenericModal
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}>
<Modal.Title
componentClass='h1'
id='moreDmModalLabel'
>
<FormattedMessage
id='more_direct_channels.title'
defaultMessage='Direct Messages'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body
role='application'
>
<div role='application'>
{body}
</Modal.Body>
<Modal.Footer className='modal-footer--invisible'>
<button
id='closeModalButton'
type='button'
className='btn btn-tertiary'
>
<FormattedMessage
id='general_button.close'
defaultMessage='Close'
/>
</button>
</Modal.Footer>
</Modal>
</div>
</GenericModal>
);
}
}

View file

@ -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<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: searchButtonRef.current,
keyboardOnly: false,
},
}),
);
a11yController.resetOriginElement();
}
}, []);
focusElement(searchButtonRef, true, true);
}, [searchButtonRef, setFocused, setCurrentChannel]);
const openSearchBox = useCallback(() => {
setFocused(true);

View file

@ -1,123 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/QuickSwitchModal should match snapshot 1`] = `
<Modal
animation={false}
aria-describedby="quickSwitchHeaderWithHint"
aria-labelledby="quickSwitchHeader"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal channel-switcher"
dialogComponentClass={[Function]}
<GenericModal
ariaLabel="Find Channels"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={false}
className="a11y__modal channel-switcher"
compassDesign={true}
enforceFocus={false}
keyboard={true}
manager={
ModalManager {
"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"
>
id="quickSwitchModal"
keyboardEscape={true}
modalHeaderText={
<div
className="channel-switcher__header"
id="quickSwitchHeaderWithHint"
>
<h1
<h2
id="quickSwitchHeader"
>
<MemoizedFormattedMessage
<Memo(MemoizedFormattedMessage)
defaultMessage="Find Channels"
id="quick_switch_modal.switchChannels"
/>
</h1>
<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>
</h2>
</div>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
}
modalSubheaderText={
<div
className="channel-switcher__suggestion-box"
className="channel-switcher__hint"
id="quickSwitchHint"
>
<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],
},
},
]
<Memo(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],
}
}
renderDividers={
Array [
"mention.unread",
"mention.recent.channels",
]
}
shouldSearchCompleteText={true}
spellCheck="false"
value=""
/>
</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>
`;

View file

@ -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(
<QuickSwitchModal {...baseProps}/>,
);
const wrapper = shallowWithIntl(<QuickSwitchModal {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
describe('handleSubmit', () => {
it('should do nothing if nothing selected', () => {
const props = {...baseProps};
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
const instance = wrapper.instance() as QuickSwitchModalClass;
const wrapper = shallow<QuickSwitchModal>(
<QuickSwitchModal {...props}/>,
);
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<QuickSwitchModal>(
<QuickSwitchModal {...baseProps}/>,
);
const props = {...baseProps};
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
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<QuickSwitchModal>(
<QuickSwitchModal {...props}/>,
);
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
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<QuickSwitchModal>(
<QuickSwitchModal {...props}/>,
);
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
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<QuickSwitchModal>(
<QuickSwitchModal {...props}/>,
);
const wrapper = shallowWithIntl(<QuickSwitchModal {...props}/>);
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(
<>
<ChannelNavigator {...channelNavigatorProps}/>
<QuickSwitchModal {...baseProps}/>
</>,
<IntlProvider locale='en'>
<>
<ChannelNavigator {...channelNavigatorProps}/>
<QuickSwitchModal {...baseProps}/>
</>
</IntlProvider>,
);
userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton'));

View file

@ -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<ActionResult>;
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<Props, State> {
export class QuickSwitchModal extends React.PureComponent<Props, State> {
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<Props, State>
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<Props, State>
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<HTMLInputElement>): void => {
@ -168,15 +160,15 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
const providers: SwitchChannelProvider[] = this.channelProviders;
const header = (
<h1 id='quickSwitchHeader'>
<h2 id='quickSwitchHeader'>
<FormattedMessage
id='quick_switch_modal.switchChannels'
defaultMessage='Find Channels'
/>
</h1>
</h2>
);
let help;
let help: React.ReactNode;
if (this.props.isMobileView) {
help = (
<FormattedMessage
@ -196,71 +188,75 @@ export default class QuickSwitchModal extends React.PureComponent<Props, State>
);
}
return (
<Modal
dialogClassName='a11y__modal channel-switcher'
show={true}
onHide={this.hideOnCancel}
enforceFocus={false}
restoreFocus={false}
role='none'
aria-labelledby='quickSwitchHeader'
aria-describedby='quickSwitchHeaderWithHint'
animation={false}
const modalHeaderText = (
<div className='channel-switcher__header'>
{header}
</div>
);
const modalSubheaderText = (
<div
className='channel-switcher__hint'
id='quickSwitchHint'
>
<Modal.Header
className='modal-header'
id='quickSwitchModalLabel'
closeButton={true}
>
<div
className='channel-switcher__header'
id='quickSwitchHeaderWithHint'
>
{header}
<div
className='channel-switcher__hint'
id='quickSwitchHint'
>
{help}
</div>
</div>
</Modal.Header>
<Modal.Body>
<div className='channel-switcher__suggestion-box'>
<i className='icon icon-magnify icon-16'/>
<SuggestionBox
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={this.setSwitchBoxRef}
id='quickSwitchInput'
aria-label={Utils.localizeMessage({id: 'quick_switch_modal.input', defaultMessage: 'quick switch input'})}
className='form-control focused'
onChange={this.onChange}
value={this.state.text}
onItemSelected={this.handleSubmit}
listComponent={SuggestionList}
listPosition='bottom'
maxLength='64'
providers={providers}
completeOnTab={false}
spellCheck='false'
delayInputUpdate={true}
openWhenEmpty={true}
onSuggestionsReceived={this.handleSuggestionsReceived}
forceSuggestionsWhenBlur={true}
renderDividers={[Constants.MENTION_UNREAD, Constants.MENTION_RECENT_CHANNELS]}
shouldSearchCompleteText={true}
/>
{!this.state.shouldShowLoadingSpinner && !this.state.hasSuggestions && this.state.text &&
{help}
</div>
);
return (
<GenericModal
className='a11y__modal channel-switcher'
id='quickSwitchModal'
show={true}
bodyPadding={false}
enforceFocus={false}
onExited={this.hideOnCancel}
onHide={this.hideOnCancel}
ariaLabel={this.props.intl.formatMessage({id: 'quick_switch_modal.switchChannels', defaultMessage: 'Find Channels'})}
modalHeaderText={modalHeaderText}
modalSubheaderText={modalSubheaderText}
compassDesign={true}
>
<div className='channel-switcher__suggestion-box'>
<i className='icon icon-magnify icon-16'/>
<SuggestionBox
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={this.setSwitchBoxRef}
id='quickSwitchInput'
aria-label={this.props.intl.formatMessage({id: 'quick_switch_modal.input', defaultMessage: 'quick switch input'})}
className='form-control focused'
onChange={this.onChange}
value={this.state.text}
onItemSelected={this.handleSubmit}
listComponent={SuggestionList}
listPosition='bottom'
maxLength='64'
providers={providers}
completeOnTab={false}
spellCheck='false'
delayInputUpdate={true}
openWhenEmpty={true}
onSuggestionsReceived={this.handleSuggestionsReceived}
forceSuggestionsWhenBlur={true}
renderDividers={[Constants.MENTION_UNREAD, Constants.MENTION_RECENT_CHANNELS]}
shouldSearchCompleteText={true}
/>
{
!this.state.shouldShowLoadingSpinner &&
!this.state.hasSuggestions &&
this.state.text &&
(
<NoResultsIndicator
variant={NoResultsVariant.Search}
titleValues={{channelName: `${this.state.pretext}`}}
/>
}
</div>
</Modal.Body>
</Modal>
)
}
</div>
</GenericModal>
);
};
}
export default injectIntl(QuickSwitchModal);

View file

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

View file

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

View file

@ -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<Props> {
class ChannelNavigator extends React.PureComponent<Props> {
componentDidMount() {
document.addEventListener('keydown', this.handleShortcut);
document.addEventListener('keydown', this.handleQuickSwitchKeyPress);
@ -45,6 +46,7 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
this.props.actions.openModal({
modalId: ModalIdentifiers.QUICK_SWITCH,
dialogType: QuickSwitchModal,
dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'},
});
};
@ -81,6 +83,7 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
openModal({
modalId: ModalIdentifiers.QUICK_SWITCH,
dialogType: QuickSwitchModal,
dialogProps: {focusOriginElement: 'SidebarChannelNavigatorButton'},
});
}
};
@ -92,9 +95,10 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
<button
className={'SidebarChannelNavigator_jumpToButton'}
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'
data-testid='SidebarChannelNavigatorButton'
id='SidebarChannelNavigatorButton'
>
<i className='icon icon-magnify'/>
<FormattedMessage
@ -109,3 +113,5 @@ export default class ChannelNavigator extends React.PureComponent<Props> {
);
}
}
export default injectIntl(ChannelNavigator);

View file

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

View file

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

View file

@ -189,7 +189,6 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
draggable='false'
className={'SidebarChannel noFloat newChannelSpacer'}
{...provided.draggableProps}
role='listitem'
tabIndex={-1}
/>
);
@ -254,7 +253,7 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
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<Props, State> {
}
>
<button
id='newDirectMessageButton'
className='SidebarChannelGroupHeader_addButton'
onClick={this.handleOpenDirectMessagesModal}
aria-label={addHelpLabel}
@ -380,7 +380,6 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
className={classNames('SidebarChannelGroup_content')}
>
<ul
role='list'
className='NavGroupContent'
>
{this.renderNewDropBox(droppableSnapshot.isDraggingOver)}

View file

@ -23,8 +23,8 @@ import Search from 'components/search/index';
import RhsPlugin from 'plugins/rhs_plugin';
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 {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard';
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))) {
this.previousActiveElement = document.activeElement as HTMLElement;
// Focus the sidebar after a tick
setTimeout(() => {
if (this.sidebarRight.current) {
document.dispatchEvent(
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.sidebarRight.current,
keyboardOnly: false,
},
}),
);
focusElement(this.sidebarRight, false);
}
}, 0);
} else if (!this.props.isOpen && wasOpen) {
// 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) {
a11yController.restoreOriginFocus();
} else {
setTimeout(() => {
if (this.previousActiveElement) {
document.dispatchEvent(
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.previousActiveElement,
keyboardOnly: false,
},
}),
);
focusElement(this.previousActiveElement, false);
this.previousActiveElement = null;
}
}, 0);

View file

@ -2,7 +2,7 @@
@use 'utils/mixins';
// 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;
}

View file

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

View file

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

View file

@ -395,14 +395,13 @@ export default class A11yController {
restoreOriginFocus() {
if (this.originElement && this.isElementValid(this.originElement)) {
// Dispatch a focus event to manually focus this element
document.dispatchEvent(
new CustomEvent(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.originElement,
keyboardOnly: false,
},
}),
);
const customEvent = new CustomEvent(A11yCustomEventTypes.FOCUS, {
detail: {
target: this.originElement,
keyboardOnly: false,
},
});
this.handleA11yFocus(customEvent);
setTimeout(() => {
this.originElement = null;
}, 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() {
this.clearActiveRegion();

View file

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

View file

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

View file

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