Mm 61590 - trap focus in modals (#30622)
* MM-61590 - trap focus in modals * Adjust focus trap for dynamic loading elements and multi modal support * add tests and improve code in generic modal * fix snapshot --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
@@ -3,7 +3,6 @@
|
||||
exports[`components/BrowseChannels should match snapshot and state 1`] = `
|
||||
<GenericModal
|
||||
aria-modal={true}
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={false}
|
||||
bodyPadding={false}
|
||||
compassDesign={true}
|
||||
@@ -32,18 +31,13 @@ exports[`components/BrowseChannels should match snapshot and state 1`] = `
|
||||
</Memo(TeamPermissionGate)>
|
||||
}
|
||||
id="browseChannelsModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Browse Channels"
|
||||
id="more_channels.title"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<injectIntl(SearchableChannelList)
|
||||
canShowArchivedChannels={true}
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
|
||||
exports[`components/drafts/draft_actions/delete_draft_modal should have called onConfirm 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
autoFocusConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
compassDesign={true}
|
||||
confirmButtonText="Yes, delete"
|
||||
enforceFocus={true}
|
||||
handleCancel={[Function]}
|
||||
handleConfirm={
|
||||
[MockFunction] {
|
||||
@@ -23,15 +19,9 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
||||
],
|
||||
}
|
||||
}
|
||||
id="genericModal"
|
||||
isDeleteModal={true}
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Delete draft"
|
||||
modalLocation="center"
|
||||
onExited={[MockFunction]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
||||
@@ -48,20 +38,13 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
||||
|
||||
exports[`components/drafts/draft_actions/delete_draft_modal should have called onExited 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
autoFocusConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
compassDesign={true}
|
||||
confirmButtonText="Yes, delete"
|
||||
enforceFocus={true}
|
||||
handleCancel={[Function]}
|
||||
handleConfirm={[MockFunction]}
|
||||
id="genericModal"
|
||||
isDeleteModal={true}
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Delete draft"
|
||||
modalLocation="center"
|
||||
onExited={
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
@@ -75,9 +58,6 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
|
||||
],
|
||||
}
|
||||
}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Are you sure you want to delete this draft to <strong>{displayName}</strong>?"
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
exports[`components/drafts/draft_actions/send_draft_modal should have called onConfirm 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
compassDesign={true}
|
||||
confirmButtonText="Yes, send now"
|
||||
enforceFocus={true}
|
||||
handleCancel={[Function]}
|
||||
handleConfirm={
|
||||
[MockFunction] {
|
||||
@@ -22,14 +18,8 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onC
|
||||
],
|
||||
}
|
||||
}
|
||||
id="genericModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Send message now"
|
||||
modalLocation="center"
|
||||
onExited={[MockFunction]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
||||
@@ -46,18 +36,11 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onC
|
||||
|
||||
exports[`components/drafts/draft_actions/send_draft_modal should have called onExited 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
compassDesign={true}
|
||||
confirmButtonText="Yes, send now"
|
||||
enforceFocus={true}
|
||||
handleCancel={[Function]}
|
||||
handleConfirm={[MockFunction]}
|
||||
id="genericModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Send message now"
|
||||
modalLocation="center"
|
||||
onExited={
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
@@ -71,9 +54,6 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onE
|
||||
],
|
||||
}
|
||||
}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<MemoizedFormattedMessage
|
||||
defaultMessage="Are you sure you want to send this message to <strong>{displayName}</strong>?"
|
||||
|
||||
@@ -499,21 +499,13 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -1103,21 +1095,13 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
|
||||
@@ -488,21 +488,13 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
|
||||
@@ -550,26 +550,18 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Edit Outgoing OAuth Connection"
|
||||
id="update_outgoing_oauth_connection.confirm"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -673,21 +665,13 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -1310,26 +1294,18 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Edit Outgoing OAuth Connection"
|
||||
id="update_outgoing_oauth_connection.confirm"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -1433,21 +1409,13 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -2069,26 +2037,18 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal integrations-backstage-modal"
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Edit Outgoing OAuth Connection"
|
||||
id="update_outgoing_oauth_connection.confirm"
|
||||
/>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
@@ -2192,21 +2152,13 @@ https://myothersite.com/api/v2"
|
||||
>
|
||||
<GenericModal
|
||||
ariaLabelledby="confirmModalLabel"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
className="ConfirmModal a11y__modal "
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
id="confirmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText="Save Outgoing OAuth Connection"
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={false}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<Modal
|
||||
animation={true}
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
exports[`components/MoreDirectChannels should exclude deleted users if there is not direct channel between users 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={false}
|
||||
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
delayFocusTrap={true}
|
||||
id="moreDmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Direct Messages"
|
||||
@@ -21,8 +18,6 @@ exports[`components/MoreDirectChannels should exclude deleted users if there is
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<div
|
||||
role="application"
|
||||
@@ -251,14 +246,11 @@ exports[`components/MoreDirectChannels should exclude deleted users if there is
|
||||
|
||||
exports[`components/MoreDirectChannels should match snapshot 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={false}
|
||||
className="a11y__modal more-modal more-direct-channels more-direct-channels-generic-modal"
|
||||
compassDesign={true}
|
||||
enforceFocus={true}
|
||||
delayFocusTrap={true}
|
||||
id="moreDmModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Direct Messages"
|
||||
@@ -270,8 +262,6 @@ exports[`components/MoreDirectChannels should match snapshot 1`] = `
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<div
|
||||
role="application"
|
||||
|
||||
@@ -296,6 +296,7 @@ export default class MoreDirectChannels extends React.PureComponent<Props, State
|
||||
bodyPadding={false}
|
||||
onEntered={this.loadModalData}
|
||||
modalLocation={'top'}
|
||||
delayFocusTrap={true}
|
||||
>
|
||||
<div role='application'>
|
||||
{body}
|
||||
|
||||
@@ -225,7 +225,6 @@ export default class MultiSelectList<T extends Value> extends React.PureComponen
|
||||
id='multiSelectList'
|
||||
className='more-modal__options'
|
||||
role='presentation'
|
||||
aria-hidden={true}
|
||||
>
|
||||
{optionControls}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={false}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
cancelButtonText={null}
|
||||
className="productNotices"
|
||||
compassDesign={true}
|
||||
@@ -13,21 +12,14 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
|
||||
Download
|
||||
</span>
|
||||
}
|
||||
enforceFocus={true}
|
||||
handleConfirm={[Function]}
|
||||
handleEnterKeyPress={[Function]}
|
||||
id="genericModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<span>
|
||||
title
|
||||
</span>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<span
|
||||
className="productNotices__helpText"
|
||||
@@ -49,7 +41,6 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={false}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={true}
|
||||
cancelButtonText={
|
||||
<React.Fragment>
|
||||
<Memo(PreviousIcon) />
|
||||
@@ -67,22 +58,15 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
||||
id="generic.done"
|
||||
/>
|
||||
}
|
||||
enforceFocus={true}
|
||||
handleCancel={[Function]}
|
||||
handleConfirm={[Function]}
|
||||
handleEnterKeyPress={[Function]}
|
||||
id="genericModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<span>
|
||||
title
|
||||
</span>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<span
|
||||
className="productNotices__helpText"
|
||||
@@ -130,7 +114,6 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
||||
<GenericModal
|
||||
autoCloseOnCancelButton={false}
|
||||
autoCloseOnConfirmButton={false}
|
||||
bodyPadding={true}
|
||||
cancelButtonText={null}
|
||||
className="productNotices"
|
||||
compassDesign={true}
|
||||
@@ -143,21 +126,14 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
||||
<Memo(NextIcon) />
|
||||
</React.Fragment>
|
||||
}
|
||||
enforceFocus={true}
|
||||
handleConfirm={[Function]}
|
||||
handleEnterKeyPress={[Function]}
|
||||
id="genericModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<span>
|
||||
for sysadmin
|
||||
</span>
|
||||
}
|
||||
modalLocation="center"
|
||||
onExited={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<span
|
||||
className="productNotices__helpText"
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
||||
<GenericModal
|
||||
ariaLabel="Find Channels"
|
||||
autoCloseOnCancelButton={true}
|
||||
autoCloseOnConfirmButton={true}
|
||||
bodyPadding={false}
|
||||
className="a11y__modal channel-switcher"
|
||||
compassDesign={true}
|
||||
enforceFocus={false}
|
||||
id="quickSwitchModal"
|
||||
keyboardEscape={true}
|
||||
modalHeaderText={
|
||||
<div
|
||||
className="channel-switcher__header"
|
||||
@@ -25,7 +22,6 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
modalLocation="center"
|
||||
modalSubheaderText={
|
||||
<div
|
||||
className="channel-switcher__hint"
|
||||
@@ -45,8 +41,6 @@ exports[`components/QuickSwitchModal should match snapshot 1`] = `
|
||||
onExited={[Function]}
|
||||
onHide={[Function]}
|
||||
show={true}
|
||||
showCloseButton={true}
|
||||
showHeader={true}
|
||||
>
|
||||
<div
|
||||
className="channel-switcher__suggestion-box"
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 {act, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import Constants from 'utils/constants';
|
||||
|
||||
describe('components/QuickSwitchModal', () => {
|
||||
@@ -149,7 +149,7 @@ describe('components/QuickSwitchModal', () => {
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should restore focus to button', () => {
|
||||
it('should restore focus to button', async () => {
|
||||
const channelNavigatorProps = {
|
||||
showUnreadsCategory: false,
|
||||
isQuickSwitcherOpen: false,
|
||||
@@ -168,8 +168,10 @@ describe('components/QuickSwitchModal', () => {
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton'));
|
||||
userEvent.keyboard('{escape}');
|
||||
await act(async () => {
|
||||
userEvent.click(await screen.getByTestId('SidebarChannelNavigatorButton'));
|
||||
userEvent.keyboard('{escape}');
|
||||
});
|
||||
expect(screen.getByTestId('SidebarChannelNavigatorButton')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,8 +33,10 @@ describe('components/TeamGroupsManageModal', () => {
|
||||
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
||||
expect(await wrapper.findByTestId('group-name')).toBeInTheDocument();
|
||||
userEvent.click(wrapper.getByTestId('menu-button'));
|
||||
|
||||
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
||||
expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument();
|
||||
|
||||
expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call loadItems on render', async () => {
|
||||
@@ -44,10 +46,12 @@ describe('components/TeamGroupsManageModal', () => {
|
||||
|
||||
test('should hide confirm modal when cancel button is clicked', async () => {
|
||||
const wrapper = renderWithContext(<TeamGroupsManageModal {...baseProps}/>);
|
||||
await wrapper.findByTestId('group-name');
|
||||
expect(await wrapper.findByTestId('group-name')).toBeInTheDocument();
|
||||
userEvent.click(wrapper.getByTestId('menu-button'));
|
||||
|
||||
userEvent.click(wrapper.getByTestId('remove-group-button'));
|
||||
expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument();
|
||||
expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(wrapper.getByTestId('cancel-button'));
|
||||
await waitForElementToBeRemoved(() => wrapper.queryByTestId('confirm-modal'));
|
||||
expect(wrapper.queryByTestId('confirm-modal')).toBeNull();
|
||||
|
||||
@@ -368,6 +368,7 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
|
||||
bodyPadding={false}
|
||||
modalHeaderText={headerTitle}
|
||||
modalLocation='top'
|
||||
delayFocusTrap={true}
|
||||
>
|
||||
<div
|
||||
ref={this.modalBodyRef}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {useFocusTrap} from '../hooks/useFocusTrap';
|
||||
import './generic_modal.scss';
|
||||
|
||||
export type ModalLocation = 'top' | 'center' | 'bottom';
|
||||
@@ -28,7 +29,7 @@ export type Props = {
|
||||
cancelButtonClassName?: string;
|
||||
isConfirmDisabled?: boolean;
|
||||
isDeleteModal?: boolean;
|
||||
id: string;
|
||||
id?: string;
|
||||
autoCloseOnCancelButton?: boolean;
|
||||
autoCloseOnConfirmButton?: boolean;
|
||||
enforceFocus?: boolean;
|
||||
@@ -67,249 +68,277 @@ export type Props = {
|
||||
* in tests (Cypress, Playwright, etc.)
|
||||
*/
|
||||
dataTestId?: string;
|
||||
|
||||
/**
|
||||
* Whether to delay activating the focus trap.
|
||||
*
|
||||
* This is useful for modals with dynamic content that might not be fully
|
||||
* rendered when the modal is opened. The delay allows the DOM to settle
|
||||
* before the focus trap identifies focusable elements. ie. MultiSelect
|
||||
*
|
||||
* When true, applies a 500ms delay.
|
||||
*/
|
||||
delayFocusTrap?: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
show: boolean;
|
||||
isFocalTrapActive: boolean;
|
||||
}
|
||||
export class GenericModal extends React.PureComponent<Props, State> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
show: true,
|
||||
id: 'genericModal',
|
||||
autoCloseOnCancelButton: true,
|
||||
autoCloseOnConfirmButton: true,
|
||||
enforceFocus: true,
|
||||
keyboardEscape: true,
|
||||
bodyPadding: true,
|
||||
showCloseButton: true,
|
||||
showHeader: true,
|
||||
modalLocation: 'center',
|
||||
};
|
||||
export const GenericModal: React.FC<Props> = ({
|
||||
show = true,
|
||||
id = 'genericModal',
|
||||
autoCloseOnCancelButton = true,
|
||||
autoCloseOnConfirmButton = true,
|
||||
enforceFocus = true,
|
||||
keyboardEscape = true,
|
||||
bodyPadding = true,
|
||||
showCloseButton = true,
|
||||
showHeader = true,
|
||||
modalLocation = 'center',
|
||||
className,
|
||||
onExited,
|
||||
onEntered,
|
||||
onHide,
|
||||
modalHeaderText,
|
||||
modalSubheaderText,
|
||||
handleCancel,
|
||||
handleConfirm,
|
||||
handleEnterKeyPress,
|
||||
handleKeydown,
|
||||
confirmButtonText,
|
||||
confirmButtonClassName,
|
||||
cancelButtonText,
|
||||
cancelButtonClassName,
|
||||
isConfirmDisabled,
|
||||
isDeleteModal,
|
||||
container,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
errorText,
|
||||
compassDesign,
|
||||
backdrop,
|
||||
backdropClassName,
|
||||
tabIndex,
|
||||
children,
|
||||
autoFocusConfirmButton,
|
||||
headerInput,
|
||||
bodyDivider,
|
||||
bodyOverflowVisible,
|
||||
footerContent,
|
||||
footerDivider,
|
||||
appendedContent,
|
||||
headerButton,
|
||||
dataTestId,
|
||||
delayFocusTrap,
|
||||
}) => {
|
||||
// Create a ref for the modal container
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const [showState, setShowState] = useState(show);
|
||||
|
||||
this.state = {
|
||||
show: props.show!,
|
||||
isFocalTrapActive: false,
|
||||
};
|
||||
}
|
||||
// Use focus trap to keep focus within the modal when it's open
|
||||
useFocusTrap(showState, containerRef, {
|
||||
delayMs: delayFocusTrap ? 500 : undefined,
|
||||
});
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.show !== this.props.show) {
|
||||
this.setState({show: Boolean(this.props.show)});
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
setShowState(show);
|
||||
}, [show]);
|
||||
|
||||
onHide = () => {
|
||||
this.setState({show: false});
|
||||
this.props.onHide?.();
|
||||
};
|
||||
const onHideCallback = useCallback(() => {
|
||||
setShowState(false);
|
||||
onHide?.();
|
||||
}, [onHide]);
|
||||
|
||||
handleCancel = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
const handleCancelCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
if (this.props.autoCloseOnCancelButton) {
|
||||
this.onHide();
|
||||
if (autoCloseOnCancelButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
if (this.props.handleCancel) {
|
||||
this.props.handleCancel();
|
||||
}
|
||||
};
|
||||
handleCancel?.();
|
||||
}, [autoCloseOnCancelButton, onHideCallback, handleCancel]);
|
||||
|
||||
handleConfirm = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
const handleConfirmCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
if (this.props.autoCloseOnConfirmButton) {
|
||||
this.onHide();
|
||||
if (autoCloseOnConfirmButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
if (this.props.handleConfirm) {
|
||||
this.props.handleConfirm();
|
||||
}
|
||||
};
|
||||
handleConfirm?.();
|
||||
}, [autoCloseOnConfirmButton, onHideCallback, handleConfirm]);
|
||||
|
||||
private onEnterKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const onEnterKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (this.props.handleConfirm && this.props.autoCloseOnConfirmButton) {
|
||||
this.onHide();
|
||||
}
|
||||
if (this.props.handleEnterKeyPress) {
|
||||
this.props.handleEnterKeyPress();
|
||||
if (handleConfirm && autoCloseOnConfirmButton) {
|
||||
onHideCallback();
|
||||
}
|
||||
handleEnterKeyPress?.();
|
||||
}
|
||||
this.props.handleKeydown?.(event);
|
||||
};
|
||||
handleKeydown?.(event);
|
||||
}, [handleConfirm, autoCloseOnConfirmButton, onHideCallback, handleEnterKeyPress, handleKeydown]);
|
||||
|
||||
render() {
|
||||
let confirmButton;
|
||||
if (this.props.handleConfirm) {
|
||||
const isConfirmOrDeleteClassName = this.props.isDeleteModal ? 'delete' : 'confirm';
|
||||
let confirmButtonText: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.confirm'
|
||||
defaultMessage='Confirm'
|
||||
/>
|
||||
);
|
||||
if (this.props.confirmButtonText) {
|
||||
confirmButtonText = this.props.confirmButtonText;
|
||||
}
|
||||
|
||||
confirmButton = (
|
||||
<button
|
||||
autoFocus={this.props.autoFocusConfirmButton}
|
||||
type='submit'
|
||||
className={classNames('GenericModal__button btn btn-primary', isConfirmOrDeleteClassName, this.props.confirmButtonClassName, {
|
||||
disabled: this.props.isConfirmDisabled,
|
||||
})}
|
||||
onClick={this.handleConfirm}
|
||||
disabled={this.props.isConfirmDisabled}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
let cancelButton;
|
||||
if (this.props.handleCancel) {
|
||||
let cancelButtonText: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
);
|
||||
if (this.props.cancelButtonText) {
|
||||
cancelButtonText = this.props.cancelButtonText;
|
||||
}
|
||||
|
||||
cancelButton = (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('GenericModal__button btn btn-tertiary', this.props.cancelButtonClassName)}
|
||||
onClick={this.handleCancel}
|
||||
>
|
||||
{cancelButtonText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const headerText = this.props.modalHeaderText && (
|
||||
<div className='GenericModal__header'>
|
||||
<h1
|
||||
id='genericModalLabel'
|
||||
className='modal-title'
|
||||
>
|
||||
{this.props.modalHeaderText}
|
||||
</h1>
|
||||
{this.props.headerButton}
|
||||
</div>
|
||||
// Build confirm button if provided.
|
||||
let confirmButtonElement;
|
||||
if (handleConfirm) {
|
||||
const buttonTypeClass = isDeleteModal ? 'delete' : 'confirm';
|
||||
let confirmButtonTextContent: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.confirm'
|
||||
defaultMessage='Confirm'
|
||||
/>
|
||||
);
|
||||
|
||||
const locationClassMapping: Record<Required<Props>['modalLocation'], string> = {
|
||||
top: 'GenericModal__location--top',
|
||||
center: 'GenericModal__location--center',
|
||||
bottom: 'GenericModal__location--bottom',
|
||||
};
|
||||
|
||||
const modalLocationClass = locationClassMapping[this.props.modalLocation ?? 'center'];
|
||||
|
||||
// Accessibility labeling strategy:
|
||||
// 1. We always set aria-labelledby to ensure the modal has a proper label
|
||||
// - First try to use the provided ariaLabeledBy prop
|
||||
// - Fall back to 'genericModalLabel' which references the modal title
|
||||
// 2. We also support aria-label as a secondary option
|
||||
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
|
||||
// - This provides a fallback for accessibility in case the referenced element is missing
|
||||
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
|
||||
const ariaLabelledby = this.props.ariaLabelledby || 'genericModalLabel';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id={this.props.id}
|
||||
role='none'
|
||||
aria-label={this.props.ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-modal='true'
|
||||
dialogClassName={classNames(
|
||||
modalLocationClass,
|
||||
'a11y__modal GenericModal',
|
||||
{
|
||||
GenericModal__compassDesign: this.props.compassDesign,
|
||||
'modal--overflow': this.props.bodyOverflowVisible,
|
||||
},
|
||||
this.props.className,
|
||||
)}
|
||||
show={this.state.show}
|
||||
restoreFocus={true}
|
||||
enforceFocus={this.props.enforceFocus}
|
||||
onHide={this.onHide}
|
||||
onExited={this.props.onExited}
|
||||
backdrop={this.props.backdrop}
|
||||
backdropClassName={this.props.backdropClassName}
|
||||
container={this.props.container}
|
||||
keyboard={this.props.keyboardEscape}
|
||||
onEntered={this.props.onEntered}
|
||||
data-testid={this.props.dataTestId}
|
||||
if (confirmButtonText) {
|
||||
confirmButtonTextContent = confirmButtonText;
|
||||
}
|
||||
confirmButtonElement = (
|
||||
<button
|
||||
autoFocus={autoFocusConfirmButton}
|
||||
type='submit'
|
||||
className={classNames('GenericModal__button btn btn-primary', buttonTypeClass, confirmButtonClassName, {
|
||||
disabled: isConfirmDisabled,
|
||||
})}
|
||||
onClick={handleConfirmCallback}
|
||||
disabled={isConfirmDisabled}
|
||||
>
|
||||
<div
|
||||
onKeyDown={this.onEnterKeyDown}
|
||||
tabIndex={this.props.tabIndex || 0}
|
||||
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
|
||||
>
|
||||
{this.props.showHeader && <Modal.Header closeButton={this.props.showCloseButton}>
|
||||
<div
|
||||
className='GenericModal__header__text_container'
|
||||
>
|
||||
{this.props.compassDesign && (
|
||||
<>
|
||||
{headerText}
|
||||
{this.props.headerInput}
|
||||
</>
|
||||
)}
|
||||
{
|
||||
this.props.modalSubheaderText &&
|
||||
<div className='modal-subheading-container'>
|
||||
<div
|
||||
id='genericModalSubheading'
|
||||
className='modal-subheading'
|
||||
>
|
||||
{this.props.modalSubheaderText}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Modal.Header>}
|
||||
<Modal.Body className={classNames({divider: this.props.bodyDivider, 'overflow-visible': this.props.bodyOverflowVisible})}>
|
||||
{this.props.compassDesign ? (
|
||||
this.props.errorText && (
|
||||
<div className='genericModalError'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
<span>{this.props.errorText}</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
headerText
|
||||
)}
|
||||
<div className={classNames('GenericModal__body', {padding: this.props.bodyPadding})}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
{(cancelButton || confirmButton || this.props.footerContent) && (
|
||||
<Modal.Footer className={classNames({divider: this.props.footerDivider})}>
|
||||
{(cancelButton || confirmButton) ? (
|
||||
<>
|
||||
{cancelButton}
|
||||
{confirmButton}
|
||||
</>
|
||||
) : (
|
||||
this.props.footerContent
|
||||
)}
|
||||
</Modal.Footer>
|
||||
)}
|
||||
{Boolean(this.props.appendedContent) && this.props.appendedContent}
|
||||
</div>
|
||||
</Modal>
|
||||
{confirmButtonTextContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build cancel button if provided.
|
||||
let cancelButtonElement;
|
||||
if (handleCancel) {
|
||||
let cancelButtonTextContent: React.ReactNode = (
|
||||
<FormattedMessage
|
||||
id='generic_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
);
|
||||
if (cancelButtonText) {
|
||||
cancelButtonTextContent = cancelButtonText;
|
||||
}
|
||||
cancelButtonElement = (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('GenericModal__button btn btn-tertiary', cancelButtonClassName)}
|
||||
onClick={handleCancelCallback}
|
||||
>
|
||||
{cancelButtonTextContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Build header text if provided.
|
||||
const headerText = modalHeaderText && (
|
||||
<div className='GenericModal__header'>
|
||||
<h1 id='genericModalLabel' className='modal-title'>
|
||||
{modalHeaderText}
|
||||
</h1>
|
||||
{headerButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Map modalLocation to a CSS class.
|
||||
const locationClassMapping: Record<ModalLocation, string> = {
|
||||
top: 'GenericModal__location--top',
|
||||
center: 'GenericModal__location--center',
|
||||
bottom: 'GenericModal__location--bottom',
|
||||
};
|
||||
const modalLocationClass = locationClassMapping[modalLocation];
|
||||
|
||||
// Accessibility labeling strategy:
|
||||
// 1. We always set aria-labelledby to ensure the modal has a proper label
|
||||
// - First try to use the provided ariaLabeledBy prop
|
||||
// - Fall back to 'genericModalLabel' which references the modal title
|
||||
// 2. We also support aria-label as a secondary option
|
||||
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
|
||||
// - This provides a fallback for accessibility in case the referenced element is missing
|
||||
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
|
||||
const ariaLabelledbyValue = ariaLabelledby || 'genericModalLabel';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id={id}
|
||||
role='none'
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledbyValue}
|
||||
aria-modal='true'
|
||||
dialogClassName={classNames(
|
||||
modalLocationClass,
|
||||
'a11y__modal GenericModal',
|
||||
{
|
||||
GenericModal__compassDesign: compassDesign,
|
||||
'modal--overflow': bodyOverflowVisible,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
show={showState}
|
||||
restoreFocus={true}
|
||||
enforceFocus={enforceFocus}
|
||||
onHide={onHideCallback}
|
||||
onExited={onExited}
|
||||
backdrop={backdrop}
|
||||
backdropClassName={backdropClassName}
|
||||
container={container}
|
||||
keyboard={keyboardEscape}
|
||||
onEntered={onEntered}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onKeyDown={onEnterKeyDown}
|
||||
tabIndex={tabIndex || 0}
|
||||
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
|
||||
>
|
||||
{showHeader && (
|
||||
<Modal.Header closeButton={showCloseButton}>
|
||||
<div className='GenericModal__header__text_container'>
|
||||
{compassDesign && (
|
||||
<>
|
||||
{headerText}
|
||||
{headerInput}
|
||||
</>
|
||||
)}
|
||||
{modalSubheaderText && (
|
||||
<div className='modal-subheading-container'>
|
||||
<div id='genericModalSubheading' className='modal-subheading'>
|
||||
{modalSubheaderText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
)}
|
||||
<Modal.Body className={classNames({divider: bodyDivider, 'overflow-visible': bodyOverflowVisible})}>
|
||||
{compassDesign ? (
|
||||
errorText && (
|
||||
<div className='genericModalError'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
<span>{errorText}</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
headerText
|
||||
)}
|
||||
<div className={classNames('GenericModal__body', {padding: bodyPadding})}>
|
||||
{children}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
{(cancelButtonElement || confirmButtonElement || footerContent) && (
|
||||
<Modal.Footer className={classNames({divider: footerDivider})}>
|
||||
{(cancelButtonElement || confirmButtonElement) ? (
|
||||
<>
|
||||
{cancelButtonElement}
|
||||
{confirmButtonElement}
|
||||
</>
|
||||
) : (
|
||||
footerContent
|
||||
)}
|
||||
</Modal.Footer>
|
||||
)}
|
||||
{Boolean(appendedContent) && appendedContent}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericModal;
|
||||
|
||||
259
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Обычный файл
259
webapp/platform/components/src/hooks/useFocusTrap.test.tsx
Обычный файл
@@ -0,0 +1,259 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import React, {useRef} from 'react';
|
||||
|
||||
import {useFocusTrap} from './useFocusTrap';
|
||||
|
||||
// Test component that uses the hook
|
||||
function FocusTrapTestComponent({
|
||||
isActive = true,
|
||||
initialFocus = false,
|
||||
restoreFocus = false,
|
||||
delayMs = 0,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
initialFocus?: boolean;
|
||||
restoreFocus?: boolean;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(isActive, containerRef, {
|
||||
initialFocus,
|
||||
restoreFocus,
|
||||
delayMs,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-testid='container'>
|
||||
<button data-testid='button1'>Button 1</button>
|
||||
<button data-testid='button2'>Button 2</button>
|
||||
<button data-testid='button3'>Button 3</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Test component with nested focus traps
|
||||
function NestedFocusTrapsComponent() {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(true, outerRef);
|
||||
useFocusTrap(true, innerRef);
|
||||
|
||||
return (
|
||||
<div ref={outerRef} data-testid='outer-container'>
|
||||
<button data-testid='outer-button1'>Outer Button 1</button>
|
||||
<div ref={innerRef} data-testid='inner-container'>
|
||||
<button data-testid='inner-button1'>Inner Button 1</button>
|
||||
<button data-testid='inner-button2'>Inner Button 2</button>
|
||||
</div>
|
||||
<button data-testid='outer-button2'>Outer Button 2</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
beforeEach(() => {
|
||||
// Create a div to hold our rendered components
|
||||
const container = document.createElement('div');
|
||||
container.id = 'root';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create an element outside the focus trap for testing restoreFocus
|
||||
const outsideButton = document.createElement('button');
|
||||
outsideButton.setAttribute('data-testid', 'outside-button');
|
||||
outsideButton.textContent = 'Outside Button';
|
||||
document.body.appendChild(outsideButton);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
document.body.innerHTML = '';
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper function to simulate Tab key press
|
||||
const simulateTabKey = (shiftKey = false) => {
|
||||
const tabEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(tabEvent);
|
||||
};
|
||||
|
||||
// Helper function to simulate tab navigation with focus trap
|
||||
const simulateTabWithFocusTrap = (container: HTMLElement, shiftKey = false) => {
|
||||
const focusableElements = Array.from(
|
||||
container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||
) as HTMLElement[];
|
||||
|
||||
if (focusableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const currentElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Find the current element index
|
||||
const currentIndex = focusableElements.indexOf(currentElement);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// If not found, focus the first element
|
||||
firstElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
// Backward navigation
|
||||
if (currentElement === firstElement) {
|
||||
lastElement.focus();
|
||||
} else {
|
||||
const prevIndex = ((currentIndex - 1) + focusableElements.length) % focusableElements.length;
|
||||
focusableElements[prevIndex].focus();
|
||||
}
|
||||
} else if (currentElement === lastElement) {
|
||||
// Forward navigation - if at last element, go to first
|
||||
firstElement.focus();
|
||||
} else {
|
||||
// Forward navigation - go to next element
|
||||
const nextIndex = (currentIndex + 1) % focusableElements.length;
|
||||
focusableElements[nextIndex].focus();
|
||||
}
|
||||
};
|
||||
|
||||
test('should trap focus within the container', async () => {
|
||||
const {container} = render(<FocusTrapTestComponent />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||
|
||||
// Tab to the last button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||
|
||||
// Tab again should cycle back to the first button
|
||||
simulateTabWithFocusTrap(container);
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Shift+Tab should go to the last button
|
||||
simulateTabWithFocusTrap(container, true);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button3'));
|
||||
});
|
||||
|
||||
test('should set initial focus when initialFocus is true', () => {
|
||||
render(<FocusTrapTestComponent initialFocus={true} />);
|
||||
|
||||
// The first focusable element should be focused automatically
|
||||
// We need to wait for the focus to be set
|
||||
setTimeout(() => {
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button1'));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
test('should restore focus when restoreFocus is true', () => {
|
||||
// Focus the outside button first
|
||||
const outsideButton = screen.getByTestId('outside-button');
|
||||
outsideButton.focus();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
// Render the component with restoreFocus=true
|
||||
const {unmount} = render(<FocusTrapTestComponent restoreFocus={true} />);
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Focus should be restored to the outside button
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
});
|
||||
|
||||
test('should handle delay option', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const {container} = render(<FocusTrapTestComponent delayMs={500} />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button - should not be trapped yet due to delay
|
||||
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||
simulateTabKey();
|
||||
|
||||
// Advance timers
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
// Now focus the first button again and try tabbing
|
||||
button1.focus();
|
||||
simulateTabWithFocusTrap(container);
|
||||
|
||||
// Now the focus trap should be active
|
||||
expect(document.activeElement).toBe(screen.getByTestId('button2'));
|
||||
});
|
||||
|
||||
test('should not activate when isActive is false', () => {
|
||||
render(<FocusTrapTestComponent isActive={false} />);
|
||||
|
||||
// Focus the first button
|
||||
const button1 = screen.getByTestId('button1');
|
||||
button1.focus();
|
||||
expect(document.activeElement).toBe(button1);
|
||||
|
||||
// Tab to the next button - should not be trapped
|
||||
// We'll use simulateTabKey here to simulate what happens without the trap
|
||||
simulateTabKey();
|
||||
|
||||
// Focus should not be trapped within the container
|
||||
expect(document.activeElement).not.toBe(screen.getByTestId('button2'));
|
||||
});
|
||||
|
||||
test('should handle nested focus traps', () => {
|
||||
render(<NestedFocusTrapsComponent />);
|
||||
|
||||
// Focus the first inner button
|
||||
const innerButton1 = screen.getByTestId('inner-button1');
|
||||
innerButton1.focus();
|
||||
expect(document.activeElement).toBe(innerButton1);
|
||||
|
||||
// Find the inner container
|
||||
const innerContainer = screen.getByTestId('inner-container');
|
||||
|
||||
// Tab to the next button in the inner trap
|
||||
simulateTabWithFocusTrap(innerContainer);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('inner-button2'));
|
||||
|
||||
// Tab again should cycle back to the first inner button
|
||||
simulateTabWithFocusTrap(innerContainer);
|
||||
expect(document.activeElement).toBe(innerButton1);
|
||||
|
||||
// The outer trap should not interfere with the inner trap
|
||||
});
|
||||
|
||||
test('should handle empty containers gracefully', () => {
|
||||
// Create a component with no focusable elements
|
||||
function EmptyComponent() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(true, containerRef);
|
||||
return <div ref={containerRef} data-testid='empty-container'></div>;
|
||||
}
|
||||
|
||||
render(<EmptyComponent />);
|
||||
|
||||
// No errors should be thrown
|
||||
const container = screen.getByTestId('empty-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
211
webapp/platform/components/src/hooks/useFocusTrap.ts
Обычный файл
211
webapp/platform/components/src/hooks/useFocusTrap.ts
Обычный файл
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
// A global stack to hold active focus trap containers.
|
||||
// This ensures that only the topmost trap processes Tab events.
|
||||
const activeFocusTraps: HTMLElement[] = [];
|
||||
|
||||
type FocusTrapOptions = {
|
||||
initialFocus?: boolean;
|
||||
restoreFocus?: boolean;
|
||||
delayMs?: number; // Delay in milliseconds before activating the focus trap
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that traps focus within a container element.
|
||||
* When multiple focus traps are active, only the topmost one will process Tab key events.
|
||||
* @param isActive Whether the focus trap is active
|
||||
* @param containerRef A ref to the container element
|
||||
* @param options FocusTrapOptions Options for the focus trap
|
||||
* @returns void
|
||||
*/
|
||||
export function useFocusTrap(
|
||||
isActive: boolean,
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
options: FocusTrapOptions = {initialFocus: false, restoreFocus: false},
|
||||
): void {
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Add a ref to store the cached focusable elements
|
||||
const focusableElementsRef = useRef<HTMLElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!isActive || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the previously focused element for restoration if needed
|
||||
if (options.restoreFocus) {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
}
|
||||
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let trapActive = false;
|
||||
|
||||
// Function to cache focusable elements and activate the trap
|
||||
const activateFocusTrap = () => {
|
||||
// Cache the focusable elements
|
||||
focusableElementsRef.current = getFocusableElements(container);
|
||||
|
||||
// Register this focus trap (push it onto the global stack)
|
||||
activeFocusTraps.push(container);
|
||||
trapActive = true;
|
||||
|
||||
if (focusableElementsRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial focus if needed
|
||||
if (options.initialFocus && focusableElementsRef.current.length > 0) {
|
||||
focusableElementsRef.current[0].focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to refresh the cached elements if needed
|
||||
const refreshFocusableElements = () => {
|
||||
focusableElementsRef.current = getFocusableElements(container);
|
||||
};
|
||||
|
||||
// Delay the activation if delayMs is specified
|
||||
if (options.delayMs && options.delayMs > 0) {
|
||||
timeoutId = setTimeout(activateFocusTrap, options.delayMs);
|
||||
} else {
|
||||
// Activate immediately if no delay
|
||||
activateFocusTrap();
|
||||
}
|
||||
|
||||
// Handle tab key navigation - only trap Tab key, let other keys propagate
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle Tab key for focus trapping
|
||||
if (e.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if this container is the top-most active focus trap
|
||||
// AND if the focus trap has been activated (after delay)
|
||||
if (!trapActive || activeFocusTraps[activeFocusTraps.length - 1] !== container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the cached focusable elements
|
||||
const elements = focusableElementsRef.current;
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = elements[0];
|
||||
const lastElement = elements[elements.length - 1];
|
||||
|
||||
// If shift+tab on first element, move to last element
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) { // If tab on last element, move to first element
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up a MutationObserver to detect DOM changes that might affect focusable elements
|
||||
const observer = new MutationObserver(() => {
|
||||
// Only refresh if the trap is active
|
||||
if (trapActive) {
|
||||
refreshFocusableElements();
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the container for changes that might affect focusability
|
||||
observer.observe(container, {
|
||||
childList: true, // Watch for changes to child elements
|
||||
subtree: true, // Watch the entire subtree
|
||||
attributes: true, // Watch for attribute changes
|
||||
attributeFilter: ['tabindex', 'disabled'], // Only care about attributes that affect focusability
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup function
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
// Clear the timeout if component unmounts during delay
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Stop the observer
|
||||
observer.disconnect();
|
||||
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Only remove from stack if it was actually added
|
||||
if (trapActive) {
|
||||
const index = activeFocusTraps.indexOf(container);
|
||||
if (index > -1) {
|
||||
activeFocusTraps.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore focus when trap is deactivated
|
||||
if (options.restoreFocus && previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isActive, containerRef, options.initialFocus, options.restoreFocus, options.delayMs]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get all focusable elements within a container
|
||||
* @param container The container element
|
||||
* @returns An array of focusable elements
|
||||
*/
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
const elements = Array.from(container.querySelectorAll(selector)) as HTMLElement[];
|
||||
|
||||
// Filter out hidden elements
|
||||
return elements.filter((element) => isElementVisible(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an element is visible in the DOM
|
||||
* @param element The element to check
|
||||
* @returns true if the element is visible, false otherwise
|
||||
*/
|
||||
function isElementVisible(element: HTMLElement): boolean {
|
||||
// Check if the element has zero dimensions
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check computed styles for this element and its ancestors
|
||||
let currentElement: HTMLElement | null = element;
|
||||
while (currentElement) {
|
||||
const style = window.getComputedStyle(currentElement);
|
||||
|
||||
// Check common ways elements can be hidden
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0' ||
|
||||
currentElement.hasAttribute('hidden')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Ссылка в новой задаче
Block a user