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>
Этот коммит содержится в:
Pablo Vélez
2025-04-24 11:46:36 +02:00
коммит произвёл GitHub
родитель 131cf039bb
Коммит 89319cafb1
17 изменённых файлов: 740 добавлений и 392 удалений

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

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

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

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

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

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